diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index 170cc07..928b870 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -188,15 +188,18 @@ match ($action) { Auth::getInstance()->require('admin'); $srcDir = '/opt/novacpx-src'; if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src'); - $out = shell_exec("git -C " . escapeshellarg($srcDir) . " fetch origin 2>&1 && git -C " . escapeshellarg($srcDir) . " log HEAD..origin/main --oneline 2>/dev/null"); - $updates = array_values(array_filter(explode("\n", trim($out ?: '')))); - $branch = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " branch --show-current 2>/dev/null") ?: 'main'); - $commit = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " rev-parse --short HEAD 2>/dev/null") ?: ''); + // Use sudo git so www-data can access root-owned repo + $fetchOut = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " fetch origin 2>&1"); + $logOut = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " log HEAD..origin/main --oneline 2>/dev/null") ?: ''; + $updates = array_values(array_filter(explode("\n", trim($logOut)))); + $branch = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " branch --show-current 2>/dev/null") ?: 'main'); + $commit = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse --short HEAD 2>/dev/null") ?: ''); Response::success([ 'updates_available' => count($updates), 'current_commit' => $commit, 'branch' => $branch, 'commits' => $updates, + 'fetch_output' => trim($fetchOut ?: ''), ]); })(), @@ -207,44 +210,54 @@ match ($action) { $srcDir = '/opt/novacpx-src'; $webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public'; $webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2'; + $steps = []; if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src'); - $before = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); + $before = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); + $steps[] = "Before: $before"; // Backup current web root $backupDir = '/var/novacpx/backups/pre-novacpx-update-' . date('YmdHis'); shell_exec("mkdir -p " . escapeshellarg($backupDir)); shell_exec("cp -a " . escapeshellarg($webRoot) . " " . escapeshellarg("$backupDir/public") . " 2>&1"); + $steps[] = "Backup: $backupDir"; - // Pull new code - $pull = shell_exec("git -C " . escapeshellarg($srcDir) . " pull origin main 2>&1"); - $after = trim(shell_exec("git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); + // Pull new code (sudo so www-data can write root-owned repo) + $pull = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " pull origin main 2>&1"); + $steps[] = "Pull: " . trim($pull ?: '(no output)'); + + $after = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); $changed = $before !== $after; + $steps[] = "After: $after" . ($changed ? " (changed)" : " (no change)"); if ($changed) { - // Validate PHP syntax before deploying - $phpFiles = glob($srcDir . '/panel/**/*.php', GLOB_BRACE) ?: []; + // Validate PHP syntax (use php8.3; find all .php files recursively) + $phpFiles = []; + $found = shell_exec("find " . escapeshellarg("$srcDir/panel") . " -name '*.php' 2>/dev/null") ?: ''; + foreach (array_filter(explode("\n", trim($found))) as $f) { $phpFiles[] = trim($f); } + $syntaxErr = []; foreach ($phpFiles as $f) { - $check = shell_exec("php -l " . escapeshellarg($f) . " 2>&1"); + $check = shell_exec("php8.3 -l " . escapeshellarg($f) . " 2>&1"); if (!str_contains($check, 'No syntax errors')) { $syntaxErr[] = basename($f) . ': ' . trim($check); } } + $steps[] = "Syntax check: " . count($phpFiles) . " files, " . count($syntaxErr) . " errors"; if ($syntaxErr) { - // Syntax errors — abort, restore - shell_exec("git -C " . escapeshellarg($srcDir) . " reset --hard " . escapeshellarg($before) . " 2>&1"); + shell_exec("sudo git -C " . escapeshellarg($srcDir) . " reset --hard " . escapeshellarg($before) . " 2>&1"); Response::error('Update aborted — PHP syntax errors: ' . implode('; ', $syntaxErr)); } - // Deploy files to web root - shell_exec("rsync -a --delete " . escapeshellarg("$srcDir/panel/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); - shell_exec("rsync -a " . escapeshellarg("$srcDir/panel/lib/") . " " . escapeshellarg("$webRoot/lib/") . " 2>&1"); - shell_exec("rsync -a " . escapeshellarg("$srcDir/panel/api/") . " " . escapeshellarg("$webRoot/api/") . " 2>&1"); + // Deploy files to web root (sudo rsync) + shell_exec("sudo rsync -a --delete " . escapeshellarg("$srcDir/panel/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); + shell_exec("sudo rsync -a " . escapeshellarg("$srcDir/panel/lib/") . " " . escapeshellarg("$webRoot/lib/") . " 2>&1"); + shell_exec("sudo rsync -a " . escapeshellarg("$srcDir/panel/api/") . " " . escapeshellarg("$webRoot/api/") . " 2>&1"); shell_exec("cp " . escapeshellarg("$srcDir/VERSION") . " " . escapeshellarg("$webRoot/VERSION") . " 2>/dev/null"); - shell_exec("chown -R www-data:www-data " . escapeshellarg($webRoot)); + shell_exec("sudo chown -R www-data:www-data " . escapeshellarg($webRoot)); + $steps[] = "Deploy: rsync complete"; // Run pending DB migrations $migrDir = "$srcDir/db/migrations"; @@ -253,27 +266,28 @@ match ($action) { $migName = basename($sql, '.sql'); $already = $db->fetchOne("SELECT 1 FROM settings WHERE `key` = ?", ["migration_$migName"]); if (!$already) { - $db->pdo()->exec(file_get_contents($sql)); + try { $db->pdo()->exec(file_get_contents($sql)); } catch (\Throwable $e) { /* skip dupes */ } $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,NOW()) ON DUPLICATE KEY UPDATE `value`=NOW()", ["migration_$migName"]); + $steps[] = "Migration: $migName applied"; } } } // Reload PHP-FPM to pick up new code - shell_exec("systemctl reload php8.3-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || true"); + shell_exec("sudo systemctl reload php8.3-fpm 2>/dev/null || sudo systemctl reload php8.2-fpm 2>/dev/null || true"); + $steps[] = "PHP-FPM reloaded"; - // Verify panel is still up using curl (handles both HTTP and HTTPS) + // Verify panel is still up sleep(2); $port = defined('PORT_ADMIN') ? PORT_ADMIN : 8882; - $schemes = ['https','http']; $panelOk = false; - foreach ($schemes as $scheme) { + foreach (['https','http'] as $scheme) { $code = trim(shell_exec("curl -sk -o /dev/null -w '%{http_code}' {$scheme}://127.0.0.1:{$port}/api/system/version --max-time 5 2>/dev/null") ?: ''); if (in_array($code, ['200','401','302','301'])) { $panelOk = true; break; } } if (!$panelOk) { - shell_exec("rsync -a --delete " . escapeshellarg("$backupDir/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); - shell_exec("systemctl reload $webSvc 2>/dev/null"); + shell_exec("sudo rsync -a --delete " . escapeshellarg("$backupDir/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); + shell_exec("sudo systemctl reload $webSvc 2>/dev/null"); novacpx_log('error', "NovaCPX update failed — panel down after deploy; restored from backup"); Response::error('Update deployed but panel went down — auto-restored from backup. Check logs.'); } @@ -286,8 +300,9 @@ match ($action) { 'updated' => $changed, 'from_commit' => $before, 'to_commit' => $after, - 'pull_output' => $pull, + 'pull_output' => trim($pull ?? ''), 'backup_path' => $backupDir, + 'steps' => $steps, ]); })(), diff --git a/panel/assets/js/admin.js b/panel/assets/js/admin.js index 5c3d3ac..3615ecf 100644 --- a/panel/assets/js/admin.js +++ b/panel/assets/js/admin.js @@ -87,7 +87,10 @@ 'mysql-manager': mysqlManager, 'mail-server': mailServer, 'ftp-server': ftpServer, + 'nginx-proxy': nginxProxy, + sessions, wordpress, + docker, 'ssl-manager': sslManager, firewall, 'audit-log': auditLog, @@ -95,9 +98,12 @@ updates, backups, cloudflare, + 'server-options': serverOptions, + notifications, settings, }; + window._novaPages = pages; Nova.initNav(pages); await Nova.loadPage('dashboard', pages); checkUpdates(); @@ -147,8 +153,8 @@ ${Object.entries(s.services || {}).map(([svc, status]) => ` - - + + - `).join(''); + const records = Array.isArray(res.data) ? res.data : []; + const rows = records.map(r => ` + `).join(''); Nova.modal(`DNS: ${domain}`, ` -
${Nova.serviceDot(status)} ${svc}${Nova.badge(status, status === 'active' ? 'green' : 'red')}${Nova.serviceDot(status)} ${svc}${Nova.badge(status, status === 'active' ? 'green' : 'red')} @@ -178,29 +184,81 @@ // ── Server Status ────────────────────────────────────────────────────────── async function serverStatus() { - const res = await Nova.api('system', 'stats'); - const s = res?.data || {}; - return ` + const [liveRes, histRes] = await Promise.all([ + Nova.api('system', 'stats'), + Nova.api('stats', 'server'), + ]); + const s = liveRes?.data || {}; + const hist = histRes?.data?.history || []; + + const html = ` + +
+
CPU
${s.cpu?.pct??0}%
${Nova.progressBar(s.cpu?.pct||0)}
+
RAM
${s.ram?.pct??0}%
${Nova.progressBar(s.ram?.pct||0)}
+
Disk
${s.disk?.pct??0}%
${Nova.progressBar(s.disk?.pct||0)}
+
Load Avg
${(s.cpu?.load||[0]).map(v=>v.toFixed(2)).join(' / ')}
Uptime: ${s.uptime||'—'}
+
-
Real-Time Server Status - -
+
24-Hour History${hist.length} samples
-
-

CPU

${s.cpu?.pct}%

${Nova.progressBar(s.cpu?.pct||0)}
-

RAM

${s.ram?.pct}%

${Nova.progressBar(s.ram?.pct||0)}
-

Disk

${s.disk?.pct}%

${Nova.progressBar(s.disk?.pct||0)}
-
-
-

Load Average

-

${(s.cpu?.load||[]).join(' / ')}

-
-
-

Uptime

-

${s.uptime}

-
+ ${hist.length === 0 + ? '

No history yet — stats are collected every 5 minutes.
Check that the collector cron is running: */5 * * * * root /usr/bin/php /opt/novacpx/bin/collect-stats.php

' + : ''}
`; + + // Can't return html and async render chart — use a trick: render then init chart + setTimeout(() => { + const canvas = document.getElementById('stats-chart'); + if (!canvas || !hist.length) return; + if (!window.Chart) { + const s = document.createElement('script'); + s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'; + s.onload = () => initStatsChart(canvas, hist); + document.head.appendChild(s); + } else { + initStatsChart(canvas, hist); + } + }, 100); + + return html; + } + + function initStatsChart(canvas, hist) { + const labels = hist.map(r => { + const d = new Date(r.recorded_at); + return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0'); + }); + const step = Math.max(1, Math.floor(labels.length / 24)); + const sparse = labels.map((l,i) => i % step === 0 ? l : ''); + + new Chart(canvas, { + type: 'line', + data: { + labels: sparse, + datasets: [ + { label: 'CPU %', data: hist.map(r=>parseFloat(r.cpu_usage||0)), borderColor:'#6366f1', backgroundColor:'rgba(99,102,241,.1)', tension:.3, pointRadius:0, fill:true }, + { label: 'RAM %', data: hist.map(r=>parseFloat(r.ram_usage||0)), borderColor:'#0ea5e9', backgroundColor:'rgba(14,165,233,.1)', tension:.3, pointRadius:0, fill:true }, + { label: 'Disk %', data: hist.map(r=>parseFloat(r.disk_usage||0)), borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,.08)', tension:.3, pointRadius:0, fill:true }, + ], + }, + options: { + responsive: true, + animation: false, + interaction: { mode:'index', intersect:false }, + scales: { + x: { grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', maxRotation:0 } }, + y: { min:0, max:100, grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', callback: v=>v+'%' } }, + }, + plugins: { + legend: { labels:{ color:'#e2e4f0', font:{ size:12 } } }, + tooltip: { callbacks:{ label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%` } }, + }, + }, + }); } // ── Updates ──────────────────────────────────────────────────────────────── @@ -293,54 +351,349 @@ } // ── Audit Log ────────────────────────────────────────────────────────────── - async function auditLog() { - const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } }); - const rows = res?.data || []; - return ` + async function auditLog(opts = {}) { + const { page = 1, user = '', action = '', date_from = '', date_to = '' } = opts; + const params = { page, per_page: 50 }; + if (user) params.user = user; + if (action) params.action = action; + if (date_from) params.date_from = date_from; + if (date_to) params.date_to = date_to; + + const content = document.getElementById('page-content'); + const filterBar = ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
`; + + if (content) content.innerHTML = filterBar + '
Loading…
'; + + const res = await Nova.api('system', 'audit-log', { params }); + const rows = res?.data || []; + const meta = res?.meta || {}; + const total = meta.total || rows.length; + const pages = meta.pages || 1; + + const tableHtml = rows.length ? ` +
+ + + + ${rows.map((r, i) => ` + + + + + + + + + + + `).join('')} + +
TimeUserActionResourceIP
${Nova.relTime(r.created_at)}${Nova.escHtml(r.username || '—')}${Nova.escHtml(r.action)}${Nova.escHtml(r.resource || '—')}${Nova.escHtml(r.ip_address || '—')}
+
` : '

No audit entries match the current filters.

'; + + const paginationHtml = pages > 1 ? ` +
+ ${Array.from({length: pages}, (_, i) => i + 1).map(p => ` + + `).join('')} +
` : ''; + + const tableCard = `
-
Audit Log
-
- - +
+ Audit Log + ${total} entr${total !== 1 ? 'ies' : 'y'} +
+ ${tableHtml} + ${paginationHtml} +`; + + if (content) content.innerHTML = filterBar + tableCard; + else return filterBar + tableCard; + + window._alOpts = opts; + } + + window.alToggleDetail = (i) => { + const row = document.getElementById('al-detail-' + i); + if (row) row.style.display = row.style.display === 'none' ? '' : 'none'; + }; + window.alApplyFilter = () => { + auditLog({ + page: 1, + user: document.getElementById('al-user')?.value || '', + action: document.getElementById('al-action')?.value || '', + date_from: document.getElementById('al-from')?.value || '', + date_to: document.getElementById('al-to')?.value || '', + }); + }; + window.alGoPage = (p) => auditLog({ ...(window._alOpts || {}), page: p }); + + // ── PHP Manager ──────────────────────────────────────────────────────────── + async function phpManager() { + const res = await Nova.api('php', 'versions'); + const data = res?.data || {}; + const vers = data.versions || []; + const panelPhp = data.panel_php || '—'; + + return ` + + +
+
Panel PHP
+
+

NovaCPX itself runs on PHP ${panelPhp} (always the highest installed version, updated automatically when a new version is installed).

+
+
+ +
+
Installed Versions
+
+
+ ${vers.map(v => ` +
+
+ PHP ${v.version} + ${v.installed ? Nova.badge(v.fpm_active ? 'active' : 'stopped', v.fpm_active ? 'green' : 'yellow') : Nova.badge('not installed','muted')} +
+ ${v.is_default ? `
Panel default
` : ''} +
+ ${v.installed ? ` + + + ${!v.is_default ? `` : ''} + ` : ` + + `} +
+
`).join('')} +
+
+
+ +`; + } + + window.phpInstallVersion = (ver) => { + Nova.confirm(`Install PHP ${ver}? This will run apt-get and may take a minute.`, async () => { + Nova.loading(`Installing PHP ${ver}…`); + const r = await Nova.api('php', 'install-version', { method: 'POST', body: { version: ver } }); + Nova.loadingDone(); + if (r?.success) { Nova.toast(`PHP ${ver} installed`, 'success'); adminPage('php-manager'); } + else Nova.toast(r?.message || 'Install failed', 'error'); + }); + }; + + window.phpRemoveVersion = (ver) => { + Nova.confirm(`Remove PHP ${ver}? All FPM pools for this version will stop.`, async () => { + Nova.loading(`Removing PHP ${ver}…`); + const r = await Nova.api('php', 'remove-version', { method: 'POST', body: { version: ver } }); + Nova.loadingDone(); + if (r?.success) { Nova.toast(`PHP ${ver} removed`, 'success'); adminPage('php-manager'); } + else Nova.toast(r?.message || 'Remove failed', 'error'); + }, true); + }; + + window.phpFpmAction = async (ver, cmd) => { + Nova.loading(`${cmd} php${ver}-fpm…`); + const r = await Nova.api('php', 'fpm-action', { method: 'POST', body: { version: ver, command: cmd } }); + Nova.loadingDone(); + if (r?.success) { Nova.toast(r.message, 'success'); refreshSvcStatus(`php${ver}-fpm`); } + else Nova.toast(r?.message || 'Action failed', 'error'); + }; + + window.phpExtModal = async (ver) => { + const panel = document.getElementById('php-ext-panel'); + if (!panel) return; + panel.style.display = ''; + panel.innerHTML = `

Loading extensions for PHP ${ver}…

`; + panel.scrollIntoView({ behavior: 'smooth' }); + + const r = await Nova.api('php', 'version-extensions', { params: { version: ver } }); + if (!r?.success) { panel.innerHTML = `

${r?.message || 'Failed to load'}

`; return; } + + const installed = r.data.installed || []; + const available = r.data.available || []; + const notInstalled = available.filter(pkg => { + const ext = pkg.replace(/^php[\d.]+-/, ''); + return !installed.some(i => i.toLowerCase() === ext.toLowerCase() || i.toLowerCase().replace('_','-') === ext.toLowerCase()); + }); + + panel.innerHTML = ` +
+
+ PHP ${ver} Extensions +
+ + +
+
+
+
+ Add extension +
+ + or + + +
+
+
+
TimeUserActionResourceIP
+ + + ${installed.map(e => ` + + + + `).join('')} + +
ExtensionAction
${e} + +
+
+
+`; + }; + + window.phpExtFilter = (q) => { + document.querySelectorAll('.php-ext-row').forEach(row => { + row.style.display = row.dataset.ext.includes(q.toLowerCase()) ? '' : 'none'; + }); + }; + + window.phpExtInstall = async (ver) => { + const sel = document.getElementById('php-ext-add-sel')?.value; + const custom = document.getElementById('php-ext-add-custom')?.value?.trim(); + const ext = custom || sel; + if (!ext) { Nova.toast('Choose or type an extension name', 'error'); return; } + Nova.toast(`Installing ${ext} for PHP ${ver}…`, 'info', 15000); + const r = await Nova.api('php', 'install-extension', { method: 'POST', body: { version: ver, extension: ext } }); + if (r?.success) { Nova.toast(r.message, 'success'); phpExtModal(ver); } + else Nova.toast(r?.message || 'Install failed', 'error'); + }; + + window.phpExtRemove = (ver, ext) => { + Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, async () => { + const r = await Nova.api('php', 'remove-extension', { method: 'POST', body: { version: ver, extension: ext } }); + if (r?.success) { Nova.toast(r.message, 'success'); phpExtModal(ver); } + else Nova.toast(r?.message || 'Remove failed', 'error'); + }, true); + }; + + // ── Notifications (#25) ─────────────────────────────────────────────────── + async function notifications() { + const res = await Nova.api('system', 'notify-settings'); + const s = res?.data || {}; + return ` + + +
+
CyberMail Settings
+
+
+
+
+ + + Leave blank to keep existing key. Get your key from platform.cyberpersons.com +
+
+ + +
+
+ + +
+
+ + + Receives alerts for new accounts, suspensions, disk warnings +
+
+ + +
+
+
+ + +
+
+
+
+ +
+
Notification Triggers
+
+ + - ${rows.map(r => ` - - - - - - - `).join('')} + + + +
EventRecipientNotes
${Nova.relTime(r.created_at)}${r.username || '—'}${r.action}${r.resource || '—'}${r.ip_address || '—'}
Account CreatedNew user + AdminSends welcome email with credentials
Account SuspendedAccount holder + AdminIncludes suspension reason
Disk Quota ≥85%Account holder + AdminOnce per day per account (cron)
SSL Expiry ≤14 daysAccount holder + AdminOnce per threshold per domain (cron)
+

Disk quota and SSL expiry checks run daily via cron.

`; } - // ── PHP Manager ──────────────────────────────────────────────────────────── - async function phpManager() { - return ` -
-
PHP Version Manager
-
-

Manage installed PHP versions and global extensions.

-
- ${['7.4','8.1','8.2','8.3'].map(v => ` -
-
PHP ${v}
-
${Nova.badge('Active','green')}
-
- -
-
`).join('')} -
-
-

Global PHP Extensions

-

Extensions installed across all PHP versions: mbstring, curl, gd, xml, zip, opcache, redis, imagick, pdo, pdo_mysql, pdo_pgsql

-
-
-
`; - } + document.addEventListener('submit', async e => { + if (!e.target.matches('#notify-form')) return; + e.preventDefault(); + const fd = new FormData(e.target); + const body = Object.fromEntries(fd.entries()); + if (!body.cybermail_api_key) delete body.cybermail_api_key; + const res = await Nova.api('system', 'save-notify-settings', { method: 'POST', body }); + if (res?.success) Nova.toast('Notification settings saved', 'success'); + else Nova.toast(res?.message || 'Save failed', 'error'); + }); + + window.notifyTest = async () => { + const email = prompt('Send test email to:'); + if (!email) return; + const res = await Nova.api('system', 'test-notify', { method: 'POST', body: { to: email } }); + if (res?.success) Nova.toast(res.message, 'success'); + else Nova.toast(res?.message || 'Send failed', 'error'); + }; // ── Settings ─────────────────────────────────────────────────────────────── async function settings() { @@ -616,19 +969,20 @@ window.adminEditZone = async (id, domain) => { const res = await Nova.api('dns', 'records', {params:{zone_id:id}}); 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}
${Nova.escHtml(r.name)}${Nova.badge(r.type,'default')}${Nova.escHtml(r.content||r.value||'')}${r.ttl||3600}
${rows}
NameTypeValueTTL
`); +
${rows||''}
NameTypeContentTTL
No records yet.
`); }; window.adminAddRecord = (zoneId, domain) => { Nova.modal('Add Record', `
-
-
+
+
`, - ``); + ``); }; window.adminDelRecord = async (id, zoneId, domain) => { Nova.confirm('Delete this record?', async () => { @@ -685,13 +1039,13 @@
${Object.entries(svcs).map(([s,st]) => `
-
- ${s}${Nova.badge(st,st==='active'?'green':'red')} +
+ ${s}${Nova.badge(st,st==='active'?'green':'red')}
-
- - - +
+ + +
`).join('')}
@@ -704,34 +1058,47 @@ const res = await Nova.api('ssl', 'list', {params:{account_id:0}}); const certs = res?.data || []; return ` -
+ + +
- SSL Certificate Manager - + Certificates +
+ + + +
- ${certs.length ? ` + ${certs.length ? `
DomainAccountTypeExpiresDaysActions
${certs.map(c => { const days = c.days_remaining; const badge = days !== null ? Nova.badge(days+'d', days<7?'red':days<30?'yellow':'green') : Nova.badge('unknown','muted'); return ` - - - + + + `; }).join('')} -
DomainAccountTypeExpiresDaysActions
${c.domain}${c.username||'—'}${Nova.badge(c.type,'default')}${Nova.escHtml(c.domain)}${Nova.escHtml(c.username||'—')}${Nova.badge(c.type||'lets-encrypt','default')} ${c.expires_at||'—'} ${badge} - +
` - : '
No SSL certificates issued yet.
'} +
` + : '
No SSL certificates yet.
'} +
+ +
+
About SSL Options
+
+

Let's Encrypt — Free automatic SSL via Certbot. Requires a publicly reachable domain (port 80 open). Use "Issue LE for All Domains" to auto-issue for every account.

+

Custom SSL — Upload a certificate from any CA (Comodo, DigiCert, GlobalSign, etc). Paste the certificate, private key, and CA chain. Use "Generate CSR" to create a signing request to send to your CA.

+
`; } window.adminIssueBulkSSL = async () => { Nova.toast('Queuing SSL for all domains without certificates…','info',6000); - // Get all accounts, then issue SSL for each domain const accts = await Nova.api('accounts','list',{params:{limit:1000}}); let count = 0; for (const a of (accts?.data?.accounts || [])) { @@ -754,6 +1121,60 @@ else Nova.toast(r?.message,'error'); }, true); }; + window.adminGenerateCSR = () => { + Nova.modal('Generate CSR', ` +

Fill in your details. Submit to CA, keep the private key safe.

+
+
+
+
+
+
+
`, + ``); + }; + window.adminDoGenerateCSR = async () => { + const domain = document.getElementById('csr-domain')?.value?.trim(); + const country = document.getElementById('csr-country')?.value?.trim(); + const state = document.getElementById('csr-state')?.value?.trim(); + const city = document.getElementById('csr-city')?.value?.trim(); + const org = document.getElementById('csr-org')?.value?.trim(); + if (!domain) { Nova.toast('Domain required','error'); return; } + Nova.toast('Generating CSR…','info'); + const r = await Nova.api('ssl','generate-csr',{method:'POST',body:{domain,country,state,city,org}}); + if (!r?.success) { Nova.toast(r?.message||'Failed','error'); return; } + document.querySelector('.modal-overlay')?.remove(); + Nova.modal(`CSR for ${domain}`, ` +

Submit the CSR to your certificate authority. Store the private key securely — you'll need it when uploading the issued cert.

+
+
+
+
+ + `); + }; + window.adminInstallCustomSSL = () => { + Nova.modal('Upload Custom SSL Certificate', ` +

Paste the certificate and key from your CA. Chain/CA bundle is optional but recommended.

+
+
+
+
`, + ``); + }; + window.adminDoInstallCustomSSL = async () => { + const domain = document.getElementById('cssl-domain')?.value?.trim(); + const cert = document.getElementById('cssl-cert')?.value?.trim(); + const key = document.getElementById('cssl-key')?.value?.trim(); + const chain = document.getElementById('cssl-chain')?.value?.trim(); + if (!domain || !cert || !key) { Nova.toast('Domain, certificate, and key are required','error'); return; } + const r = await Nova.api('ssl','install-custom',{method:'POST',body:{domain,cert,key,chain}}); + if (r?.success) { + Nova.toast('Custom SSL installed','success'); + document.querySelector('.modal-overlay')?.remove(); + adminPage('ssl-manager'); + } else { Nova.toast(r?.message||'Failed','error'); } + }; // ── Firewall ─────────────────────────────────────────────────────────────── // ── Firewall ─────────────────────────────────────────────────────────────── @@ -1249,24 +1670,81 @@ ${ips.length ? ` // ── MySQL/DB Manager ─────────────────────────────────────────────────────── async function mysqlManager() { - const res = await Nova.api('databases','list',{params:{account_id:0}}); - const dbs = res?.data || []; - return ` + const [engRes, dbRes] = await Promise.all([ + Nova.api('system','db-engines'), + Nova.api('databases','list',{params:{account_id:0}}), + ]); + const eng = engRes?.data?.engines || {}; + const actE = engRes?.data?.active_engine || 'mysql'; + const dbs = dbRes?.data || []; + + const engineCard = (id, label, icon) => { + const e = eng[id] || {}; + const statusColor = e.active ? 'green' : (e.installed ? 'red' : 'default'); + const statusText = !e.installed ? 'Not Installed' : (e.active ? 'Running' : 'Stopped'); + return `
-
Databases
- ${dbs.length ? ` - ${dbs.map(d => ` - - - - - - - `).join('')} -
DatabaseUserTypeAccountSizeActions
${d.db_name}${d.db_user}${Nova.badge(d.db_type,'default')}${d.username||'—'}${d.size||'—'}
` - : '
No databases.
'} +
+ ${icon} ${label} + ${Nova.badge(statusText, statusColor)} + ${e.version ? `v${e.version}` : ''} +
+
+
+ ${!e.installed + ? `` + : ` + + + + ` + } +
+ ${e.installed && id !== 'postgresql' ? `phpMyAdmin ↗` : ''} + ${e.installed && id === 'postgresql' ? `pgAdmin ↗` : ''} +
+
`; + }; + + const dbTable = dbs.length ? ` + +${dbs.map(d=>` + + + + + +`).join('')} +
DatabaseUserTypeAccountActions
${Nova.escHtml(d.db_name)}${Nova.escHtml(d.db_user||'—')}${Nova.badge(d.db_type||'mysql','default')}${Nova.escHtml(d.username||'—')}
` : '
No databases yet.
'; + + return ` + + +
+ ${engineCard('mysql', 'MySQL', '🐬')} + ${engineCard('mariadb', 'MariaDB', '🦭')} + ${engineCard('postgresql','PostgreSQL','🐘')} +
+ +
+
Active EngineUsed for new account databases
+
+ + + Currently: ${Nova.badge(actE,'green')} +
+
+ +
+
All Databases${dbs.length} total
+ ${dbTable}
`; } + window.adminDropDB = (id, name) => { Nova.confirm(`Drop database ${name}? ALL DATA WILL BE LOST.`, async () => { const r = await Nova.api('databases','drop',{method:'POST',body:{id}}); @@ -1274,6 +1752,26 @@ ${ips.length ? ` else Nova.toast(r?.message,'error'); }, true); }; + window.dbEngineAction = (engine, action) => { + const labels = {install:`Installing ${engine}…`,remove:`Removing ${engine}…`,start:`Starting ${engine}…`,stop:`Stopping ${engine}…`,restart:`Restarting ${engine}…`}; + const doIt = async () => { + Nova.loading(labels[action] || `Working on ${engine}…`); + const r = await Nova.api('system','db-engine-action',{method:'POST',body:{engine,action}}); + Nova.loadingDone(); + Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error'); + if (r?.success) adminPage('mysql-manager'); + }; + if (['install','remove'].includes(action)) { + Nova.confirm(`${action === 'install' ? 'Install' : 'Remove'} ${engine}?`, doIt, action === 'remove'); + } else { doIt(); } + }; + window.dbSetActive = async () => { + const engine = document.getElementById('db-active-engine')?.value; + if (!engine) return; + const r = await Nova.api('system','db-engine-action',{method:'POST',body:{engine,action:'set-active'}}); + Nova.toast(r?.message||(r?.success?'Active engine updated':'Failed'), r?.success?'success':'error'); + if (r?.success) adminPage('mysql-manager'); + }; // ── Mail Server ──────────────────────────────────────────────────────────── async function mailServer() { @@ -1286,9 +1784,9 @@ ${ips.length ? `
Mail Services
- ${[['postfix',mailStatus],['dovecot',doveStatus],['spamassassin','unknown']].map(([s,st]) => ` + ${[['postfix',mailStatus],['dovecot',doveStatus]].map(([s,st]) => `
- ${s} ${Nova.badge(st,st==='active'?'green':'red')} + ${s} ${Nova.badge(st,st==='active'?'green':'red')}
@@ -1312,21 +1810,28 @@ ${ips.length ? ` // ── FTP Server ──────────────────────────────────────────────────────────── async function ftpServer() { - const r = await Nova.api('system','stats'); - const ftpStatus = r?.data?.services?.proftpd || 'unknown'; + const [sRes, optsRes] = await Promise.all([ + Nova.api('system','stats'), + Nova.api('system','server-options'), + ]); + const svcs = sRes?.data?.services || {}; + const ftpConf = optsRes?.data?.ftp_server || 'proftpd'; + const svcName = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'pure-ftpd' : 'proftpd'); + const label = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'Pure-FTPd' : 'ProFTPD'); + const status = svcs[svcName] || 'unknown'; return `
- FTP Server (ProFTPD) - ${Nova.badge(ftpStatus, ftpStatus==='active'?'green':'red')} + FTP Server (${label}) + ${Nova.badge(status, status==='active'?'green':'red')}
- - + +
-

ProFTPD uses virtual users stored in /etc/proftpd/novacpx-users.passwd

+

Active FTP server: ${label} — change in Server Options.

FTP connections use SFTP on port 22 or passive FTP on ports 20/21.

Per-account FTP management is available in each account's FTP page.

@@ -1338,38 +1843,48 @@ ${ips.length ? ` async function backups() { return backupsFull(); } // ── Stubs for new pages — implementations in additions block below ───────── - async function wordpress() { return `

Loading…

`; } - async function cloudflare() { return `

Loading…

`; } - async function twofa() { return `

Loading…

`; } + async function wordpress() { return wordpressPage(); } + async function cloudflare() { return cloudflarePage(); } + async function twofa() { return twofaPage(); } + async function nginxProxy() { return nginxProxyPage(); } + async function sessions() { return sessionsPage(); } // ── Global action helpers ────────────────────────────────────────────────── window.adminPage = (page) => Nova.loadPage(page, pages); window.applyNovaCPXUpdate = async () => { Nova.confirm('Apply NovaCPX update? PHP syntax is checked first, and a backup is taken automatically. The panel will self-restore if anything breaks.', async () => { - const btn = document.getElementById('ncpx-update-btn'); - if (btn) { btn.disabled = true; btn.textContent = 'Updating…'; } - Nova.toast('Pulling update from GitHub…', 'info', 12000); + Nova.loading('Pulling NovaCPX update from GitHub…'); const res = await Nova.api('system', 'apply-novacpx-update', { method: 'POST' }); - if (res?.data?.updated) { - Nova.toast(`Updated to ${res.data.to_commit}`, 'success', 6000); - setTimeout(() => Nova.loadPage('updates', pages), 2000); - } else if (res?.error) { - Nova.toast(res.error, 'error', 8000); - if (btn) { btn.disabled = false; btn.textContent = 'Update NovaCPX'; } + Nova.loadingDone(); + const d = res?.data; + if (!res?.success) { + Nova.modal('Update Failed', `

${Nova.escHtml(res?.message || 'Unknown error')}

`); + return; + } + if (d?.updated) { + const steps = (d.steps || []).map(s => `
${Nova.escHtml(s)}
`).join(''); + Nova.modal('Update Complete', + `

Updated: ${Nova.escHtml(d.from_commit)}${Nova.escHtml(d.to_commit)}

+ ${steps ? `
${steps}
` : ''} +

Backup saved to: ${Nova.escHtml(d.backup_path || '')}

`, + `` + ); } else { - Nova.toast('Already up to date.', 'info'); - if (btn) { btn.disabled = false; btn.textContent = 'Update NovaCPX'; } + Nova.modal('Already Up To Date', + `

NovaCPX is already at the latest commit: ${Nova.escHtml(d?.to_commit || '—')}

+ ${d?.pull_output ? `
${Nova.escHtml(d.pull_output)}
` : ''}`, + `` + ); } }); }; window.applyOSUpdate = async () => { Nova.confirm('Apply OS package upgrades? Services will be automatically restarted if needed. The NovaCPX panel will self-restore from backup if any ports go down.', async () => { - const btn = document.getElementById('os-update-btn'); - if (btn) { btn.disabled = true; btn.textContent = 'Upgrading…'; } - Nova.toast('Running apt-get upgrade — this may take a few minutes…', 'info', 20000); + Nova.loading('Running OS upgrade — this may take a few minutes…'); const res = await Nova.api('system', 'apply-os-update', { method: 'POST', timeout: 120000 }); + Nova.loadingDone(); if (res?.data) { const d = res.data; const healed = Object.entries(d.services_healed || {}).map(([s,r]) => `${s}: ${r}`).join(', '); @@ -1380,7 +1895,6 @@ ${ips.length ? ` Nova.loadPage('updates', pages); } else { Nova.toast(res?.error || 'Upgrade failed', 'error', 8000); - if (btn) { btn.disabled = false; btn.textContent = 'Apply OS Upgrade'; } } }); }; @@ -1388,8 +1902,42 @@ ${ips.length ? ` // keep old alias for any lingering references window.applyUpdate = window.applyNovaCPXUpdate; window.adminServiceAction = async (svc, cmd) => { + const label = { start: 'Starting', stop: 'Stopping', restart: 'Restarting', reload: 'Reloading', flush: 'Flushing queue' }[cmd] || cmd; + Nova.loading(`${label} ${svc}…`); + // Optimistic immediate badge update + const optimistic = cmd === 'stop' ? 'inactive' : cmd === 'flush' ? null : 'activating'; + if (optimistic) { + document.querySelectorAll(`[data-svc-status="${svc}"]`).forEach(el => { + el.innerHTML = Nova.badge(optimistic, optimistic === 'inactive' ? 'red' : 'yellow'); + }); + document.querySelectorAll(`[data-svc-dot="${svc}"]`).forEach(el => { + el.innerHTML = Nova.serviceDot(optimistic); + }); + } const res = await Nova.api('system', 'service', { method: 'POST', body: { service: svc, command: cmd } }); - Nova.toast(`${svc}: ${cmd} → ${res?.success ? 'OK' : res?.message}`, res?.success ? 'success' : 'error'); + Nova.loadingDone(); + if (res?.success) { + const msg = cmd === 'flush' ? `Mail queue flushed` : `${svc} ${cmd} complete`; + Nova.toast(msg, 'success'); + if (cmd !== 'flush') window.refreshSvcStatus(svc); + } else { + Nova.toast(res?.message || `${svc} ${cmd} failed`, 'error'); + if (cmd !== 'flush') window.refreshSvcStatus(svc, 0); + } + }; + + // Polls is-active and updates all [data-svc-status] / [data-svc-dot] in the DOM + window.refreshSvcStatus = async (svc, delay = 2000) => { + if (delay > 0) await new Promise(r => setTimeout(r, delay)); + const r = await Nova.api('system', 'svc-check', { params: { service: svc } }); + const status = r?.data?.status || 'unknown'; + const color = status === 'active' ? 'green' : status === 'activating' ? 'yellow' : 'red'; + document.querySelectorAll(`[data-svc-status="${svc}"]`).forEach(el => { + el.innerHTML = Nova.badge(status, color); + }); + document.querySelectorAll(`[data-svc-dot="${svc}"]`).forEach(el => { + el.innerHTML = Nova.serviceDot(status); + }); }; window.phpAction = async (ver, cmd) => { const svc = `php${ver}-fpm`; @@ -1412,7 +1960,7 @@ ${ips.length ? ` // ── ADDITIONS: appended by features #14-17 ──────────────────────────────── // ── WordPress Manager (#14) ──────────────────────────────────────────────── -async function wordpress() { +async function wordpressPage() { const [acctRes, wpRes] = await Promise.all([ Nova.api('accounts','list',{params:{limit:500}}), Nova.api('wordpress','list'), @@ -1714,7 +2262,7 @@ window.bkSaveScheduleFor = async (id) => { }; // ── Cloudflare Integration (#16) ────────────────────────────────────────── -async function cloudflare() { +async function cloudflarePage() { const acctRes = await Nova.api('accounts','list',{params:{limit:500}}); const accts = acctRes?.data?.accounts || []; window._adminAcctsCF = accts; @@ -1863,7 +2411,7 @@ window.cfPurge = async (zoneId, acctId) => { }; // ── TOTP / 2FA Admin (#17) ──────────────────────────────────────────────── -async function twofa() { +async function twofaPage() { const res = await Nova.api('accounts','list',{params:{limit:500}}); const users = res?.data?.accounts || []; return ` @@ -1919,3 +2467,787 @@ window.totpAdminDisable = (userId, username) => { } }, true); }; + +// ── Nginx Proxy Manager ─────────────────────────────────────────────────────── +async function nginxProxyPage() { + const [statusR, hostsR] = await Promise.all([ + Nova.api('proxy', 'status'), + Nova.api('proxy', 'hosts'), + ]); + const s = statusR?.data || {}; + const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); + const run = s.running; + const inst = s.installed; + + return ` + + +
+
+
Nginx Status
+
${inst ? (run ? 'Running' : 'Stopped') : 'Not Installed'}
+
${s.version || (inst ? 'nginx' : 'click Install to set up')}
+
+
+
Proxy Hosts
+
${hosts.length}
+
${hosts.filter(h => h.enabled).length} active
+
+
+
SSL Enabled
+
${hosts.filter(h => h.ssl_enabled).length}
+
of ${hosts.length} hosts
+
+
+ +${!inst ? ` +
+ +

Nginx Not Installed

+

Install Nginx on this VM to use it as a reverse proxy in front of Apache, or use a separate proxy VM (see Setup Guide).

+
+ + +
+
+` : ` +
+
+

Service Controls

+
+ + + + +
+
+
+ +
+
+

Proxy Hosts

+ ${hosts.length} total +
+ ${hosts.length === 0 ? ` +
+ No proxy hosts yet. Click Sync Accounts to auto-add all hosted domains, or + Add Host to add manually. +
+ ` : ` +
+ + + + + + + + + + ${hosts.map(h => ` + + + + + + + `).join('')} + +
DomainUpstreamSSLStatusActions
${Nova.escHtml(h.domain)}${Nova.escHtml(h.upstream)}${h.ssl_enabled ? Nova.badge('SSL','green') : Nova.badge('HTTP','muted')}${h.enabled ? Nova.badge('Active','green') : Nova.badge('Disabled','red')} + + + +
+
+ `} +
+`}`; +} + +window.proxyInstall = async () => { + if (!confirm('Install Nginx on this VM? This will run apt-get install nginx.')) return; + Nova.toast('Installing nginx...', 'info'); + const r = await Nova.api('proxy', 'install', { method: 'POST' }); + Nova.toast(r?.data?.result || r?.message || 'Done', r?.data?.result === 'installed' ? 'success' : 'info'); + Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyControl = async (action) => { + const r = await Nova.api('proxy', 'control', { method: 'POST', body: { action } }); + Nova.toast(r?.data?.result || r?.message || action + ' done', 'success'); + setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 800); +}; + +window.proxySync = async () => { + const r = await Nova.api('proxy', 'sync', { method: 'POST' }); + Nova.toast(`Synced: ${r?.data?.added ?? 0} new hosts added`, 'success'); + Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyAddHost = () => { + Nova.modal('Add Proxy Host', ` +
+
+
+ + e.g. http://127.0.0.1:80 or http://10.0.0.2:8080
+
+
+
+
+ `, async () => { + const domain = document.getElementById('ph-domain')?.value?.trim(); + const upstream = document.getElementById('ph-upstream')?.value?.trim(); + if (!domain || !upstream) { Nova.toast('Domain and upstream required', 'error'); return; } + const r = await Nova.api('proxy', 'hosts', { + method: 'POST', + body: { domain, upstream, ssl_enabled: document.getElementById('ph-ssl')?.checked ? 1 : 0 } + }); + Nova.toast(r?.success ? 'Host added' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }); +}; + +window.proxyEditHost = async (id) => { + const hostsR = await Nova.api('proxy', 'hosts'); + const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); + const h = hosts.find(x => x.id == id); + if (!h) return; + Nova.modal('Edit Proxy Host', ` +
+
+
+
+
+
+
+ + Leave blank to use auto-generated config
+ `, async () => { + const r = await Nova.api('proxy', 'host', { + method: 'PUT', + body: { id, + domain: document.getElementById('phe-domain')?.value?.trim(), + upstream: document.getElementById('phe-upstream')?.value?.trim(), + ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0, + custom_config: document.getElementById('phe-custom')?.value?.trim() || null, + } + }); + Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }); +}; + +window.proxyToggle = async (id, enable) => { + const r = await Nova.api('proxy', 'toggle', { method: 'POST', body: { id, enabled: enable } }); + Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyDeleteHost = (id, domain) => { + Nova.confirm(`Delete proxy host for ${domain}?`, async () => { + const r = await Nova.api('proxy', 'host', { method: 'DELETE', body: { id } }); + Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }, true); +}; + +window.proxySetupInstructions = async () => { + const scriptUrl = '/api/proxy/setup-script'; + Nova.modal('Nginx Proxy Setup Guide', ` +
+

Option A — Local (Nginx on this VM)

+

Install Nginx alongside Apache on this VM. Nginx listens on ports 80/443 and forwards to Apache. Best for SSL termination and caching.

+
    +
  1. Click Install Nginx Locally on the main Nginx Proxy page
  2. +
  3. Move Apache to port 8080: edit /etc/apache2/ports.conf → change Listen 80 to Listen 8080
  4. +
  5. Update upstream in all proxy hosts to http://127.0.0.1:8080
  6. +
  7. Click Sync Accounts to auto-populate proxy hosts from your hosted accounts
  8. +
  9. Click Reload Config to apply changes
  10. +
+ +

Option B — Remote Proxy VM (Recommended for production)

+

Run a dedicated Nginx proxy VM in front of this NovaCPX VM. Traffic flows: Internet → FortiGate → Nginx Proxy VM → NovaCPX VM (Apache).

+
    +
  1. Create a new VM on Proxmox (Ubuntu 22.04, 1 vCPU, 1GB RAM)
  2. +
  3. Run the setup script below on the new VM as root
  4. +
  5. Point FortiGate VIPs to the proxy VM IP (ports 80/443)
  6. +
  7. Set the proxy upstream to this NovaCPX VM IP (http://10.48.200.110:80)
  8. +
  9. Add proxy hosts for each domain from your NovaCPX admin panel
  10. +
+ +

Automated Setup Script

+

Run this on the target VM (local or remote) as root:

+
+ curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script | bash +
+

Or download and review before running:

+
+ curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script -o proxy-setup.sh
+ cat proxy-setup.sh # review
+ bash proxy-setup.sh +
+ +

Integration with VirtualHost Manager

+

When proxy mode is active, NovaCPX automatically:

+
    +
  • Creates a proxy host entry for every new account
  • +
  • Removes the proxy host when an account is terminated
  • +
  • Re-generates Nginx config on every account change
  • +
  • Uses account SSL certs automatically if SSL is enabled on the proxy host
  • +
+
+ `, null, { cancelLabel: 'Close', showConfirm: false }); +}; + +// ── #29 Session Manager ─────────────────────────────────────────────────────── +async function sessionsPage() { + const r = await Nova.api('sessions', 'list'); + const rows = r?.data || []; + const fmt = d => new Date(d.replace(' ','T')+'Z').toLocaleString(); + const ua = s => { + if (!s) return '—'; + const m = s.match(/\(([^)]+)\)/); + return m ? m[1].split(';')[0].slice(0,50) : s.slice(0,50); + }; + return ` + +
+
Active Sessions
${rows.length}
+
Unique Users
${new Set(rows.map(r=>r.user_id)).size}
+
Unique IPs
${new Set(rows.map(r=>r.ip_address)).size}
+
+
+

Active Sessions

${rows.length} total
+ ${rows.length === 0 + ? '
No active sessions
' + : `
+ + + ${rows.map(s=>` + + + + + + + `).join('')} +
UserRoleIPBrowserCreatedExpiresActions
${Nova.escHtml(s.username)}
${Nova.escHtml(s.email)}
${Nova.badge(s.role, s.role==='admin'?'red':s.role==='reseller'?'yellow':'blue')}${Nova.escHtml(s.ip_address)}${Nova.escHtml(ua(s.user_agent||''))}${fmt(s.created_at)}${fmt(s.expires_at)} + + +
`} +
`; +} + +window.sessionsRevoke = async (id) => { + const r = await Nova.api('sessions','revoke',{method:'DELETE',body:{session_id:id}}); + Nova.toast(r?.success?'Session revoked':'Failed',r?.success?'success':'error'); + if (r?.success) Nova.loadPage('sessions',window._novaPages); +}; + +window.sessionsRevokeUser = (uid,name) => { + Nova.confirm(`Revoke all sessions for ${name}? They will be logged out everywhere.`,async()=>{ + const r=await Nova.api('sessions','revoke-user',{method:'DELETE',body:{user_id:uid}}); + Nova.toast(r?.success?`${r.data?.revoked??'?'} sessions revoked`:'Failed',r?.success?'success':'error'); + if(r?.success) Nova.loadPage('sessions',window._novaPages); + },true); +}; + +window.sessionsRevokeAll = () => { + Nova.confirm('Revoke ALL sessions? Everyone including you will be logged out.',async()=>{ + const r=await Nova.api('sessions','revoke-all',{method:'DELETE',body:{}}); + Nova.toast(r?.success?'All sessions revoked — logging out...':'Failed',r?.success?'success':'error'); + if(r?.success) setTimeout(()=>location.reload(),1500); + },true); +}; + +// ── #31-35 Docker Management ─────────────────────────────────────────────── +async function docker() { + const st = await Nova.api('docker', 'status'); + const status = st?.data || {}; + + window.dockerInstall = async (btn) => { + btn.disabled = true; + Nova.loading('Installing Docker CE… (this may take 2–3 minutes)'); + const r = await Nova.api('docker', 'install', { method: 'POST', body: {} }); + Nova.loadingDone(); + Nova.toast(r?.message || (r?.success ? 'Docker installed' : 'Install failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('docker', window._novaPages); + else btn.disabled = false; + }; + + if (!status.installed) { + return ` + +
+
🐳
+

Docker is not installed

+

Install Docker CE + Compose on this server to enable container management.

+ +
`; + } + + window._dockerTab = window._dockerTab || 'containers'; + const tab = (id, label) => ``; + + window.dockerTab = async (id) => { + window._dockerTab = id; + document.querySelectorAll('[onclick^="dockerTab"]').forEach(b => { + b.className = 'btn btn-sm ' + (b.getAttribute('onclick').includes(`'${id}'`) ? 'btn-primary' : 'btn-ghost'); + }); + await dockerLoadTab(id); + }; + window.dockerPrune = () => Nova.confirm('Remove all stopped containers, unused images, and build cache?', async () => { + const r = await Nova.api('docker', 'prune', { method: 'POST', body: { volumes: false } }); + Nova.toast(r?.success ? 'Pruned' : 'Failed', r?.success ? 'success' : 'error'); + if (r?.success) dockerLoadTab(window._dockerTab); + }, true); + + setTimeout(() => dockerLoadTab(window._dockerTab), 100); + + return ` + +
+
Engine
${Nova.escHtml(status.version || '—')}
${status.running ? 'Running' : 'Stopped'}
+ ${(status.disk||[]).map(d=>`
${Nova.escHtml(d.Type||d.type||'?')}
${Nova.escHtml(d.TotalCount||d.Size||'—')}
${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}
`).join('')} +
+
+ ${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')} + +
+
Loading…
`; +} + +async function dockerLoadTab(tab) { + const tc = document.getElementById('docker-tab-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} containers + +
+${rows.length === 0 ? '
No containers
' : ` +
+ + +${rows.map(c => ` + + + + + + +`).join('')} +
NameImageStatusAccountCreatedActions
${Nova.escHtml(c.name)}${Nova.escHtml(c.image)}${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}${c.account_id || '—'}${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'} + ${c.status==='running' + ? ` + ` + : ``} + + +
`}`; + + } else if (tab === 'images') { + const r = await Nova.api('docker', 'images'); + const imgs = r?.data?.images || []; + tc.innerHTML = ` +
+ ${imgs.length} images + +
+${imgs.length === 0 ? '
No images
' : ` +
+${imgs.map(i => ` + + + + + +`).join('')} +
RepositoryTagIDSizeActions
${Nova.escHtml(i.Repository||i.repository||'—')}${Nova.escHtml(i.Tag||i.tag||'latest')}${Nova.escHtml((i.ID||i.id||'').substring(7,19))}${Nova.escHtml(i.Size||i.size||'—')}
`}`; + + } else if (tab === 'volumes') { + const r = await Nova.api('docker', 'volumes'); + const vols = r?.data?.volumes || []; + tc.innerHTML = `${vols.length} volumes +${vols.length === 0 ? '
No volumes
' : ` +
+${vols.map(v=>``).join('')} +
NameDriverScope
${Nova.escHtml(v.Name||v.name||'')}${Nova.escHtml(v.Driver||v.driver||'')}${Nova.escHtml(v.Scope||v.scope||'')}
`}`; + + } else if (tab === 'networks') { + const r = await Nova.api('docker', 'networks'); + const nets = r?.data?.networks || []; + tc.innerHTML = `${nets.length} networks +${nets.length === 0 ? '
No networks
' : ` +
+${nets.map(n=>``).join('')} +
NameDriverScopeID
${Nova.escHtml(n.Name||n.name||'')}${Nova.escHtml(n.Driver||n.driver||'')}${Nova.escHtml(n.Scope||n.scope||'')}${Nova.escHtml((n.ID||n.id||'').substring(0,12))}
`}`; + + } else if (tab === 'stacks') { + const r = await Nova.api('docker', 'stacks'); + const stacks = r?.data?.stacks || []; + tc.innerHTML = ` +
+ ${stacks.length} stacks + +
+${stacks.length === 0 ? '
No compose stacks
' : ` +
+${stacks.map(s=>` + + + + + +`).join('')} +
NameStatusAccountCreatedActions
${Nova.escHtml(s.name)}${Nova.badge(s.status, s.status==='running'?'green':s.status==='stopped'?'red':'yellow')}${s.account_id||'admin'}${new Date(s.created_at).toLocaleDateString()} + + + + +
`}`; + + } else if (tab === 'quotas') { + const r = await Nova.api('accounts', 'list', { params: { limit: 200 } }); + const users = r?.data?.accounts || []; + tc.innerHTML = ` +

Set Docker resource limits per user. Click a row to edit.

+
+${users.map(u=>` + + + + + +`).join('')} +
UsernameMax ContainersMax MemoryMax CPUsActions
${Nova.escHtml(u.username)}2512 MB1.0
`; + } +} + +window.dockerContainerAct = async (cid, action) => { + const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } }); + Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) dockerLoadTab('containers'); +}; + +window.dockerRemove = (cid) => Nova.confirm('Remove this container?', async () => { + const r = await Nova.api('docker', 'container-remove', { method: 'DELETE', body: { container_id: cid, force: true } }); + Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) dockerLoadTab('containers'); +}, true); + +window.dockerLogs = async (cid, name) => { + const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 200 } }); + const logs = r?.data?.logs || r?.message || 'No logs'; + Nova.modal(`Logs: ${name}`, `
${Nova.escHtml(logs)}
`); +}; + +window.dockerImgRemove = (id) => Nova.confirm('Remove this image?', async () => { + const r = await Nova.api('docker', 'image-remove', { method: 'DELETE', body: { image_id: id } }); + Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) dockerLoadTab('images'); +}, true); + +window.dockerPullModal = () => { + const ov = Nova.modal('Pull Image', + `
`, + ` + ` + ); + window.dockerPullSubmit = async () => { + const image = document.getElementById('di-image').value.trim(); + if (!image) return; + ov.remove(); + Nova.toast('Pulling image…', 'info', 10000); + const r = await Nova.api('docker', 'image-pull', { method: 'POST', body: { image } }); + Nova.toast(r?.success ? 'Image pulled' : (r?.message || 'Pull failed'), r?.success ? 'success' : 'error'); + if (r?.success) dockerLoadTab('images'); + }; +}; + +window.dockerRunModal = () => { + const ov = Nova.modal('Run Container', + `
+
+
+
+
+
`, + ` + ` + ); + window.dockerRunSubmit = async () => { + const image = document.getElementById('dr-image').value.trim(); + const name = document.getElementById('dr-name').value.trim(); + const acct = parseInt(document.getElementById('dr-acct').value) || 0; + const ports = document.getElementById('dr-ports').value.trim().split('\n').map(p=>p.trim()).filter(Boolean); + const mem = parseInt(document.getElementById('dr-mem').value) || 256; + const cpus = parseFloat(document.getElementById('dr-cpus').value) || 0.5; + if (!image || !name || !acct) { Nova.toast('Image, name and account required','error'); return; } + ov.remove(); + const r = await Nova.api('docker', 'container-run', { method: 'POST', body: { image, name, account_id: acct, ports, memory_mb: mem, cpus } }); + Nova.toast(r?.success ? 'Container started' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) dockerLoadTab('containers'); + }; +}; + +window.dockerStackAct = async (id, action) => { + Nova.toast(`Running docker compose ${action}…`, 'info', 5000); + const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: id, action } }); + if (action === 'logs') { + Nova.modal('Stack Logs', `
${Nova.escHtml(r?.data?.output||'')}
`); + } else { + Nova.toast(r?.success ? `Stack ${action} complete` : (r?.message||'Failed'), r?.success?'success':'error'); + if (r?.success) dockerLoadTab('stacks'); + } +}; + +window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => { + const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } }); + Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error'); + if (r?.success) dockerLoadTab('stacks'); +}, true); + +window.dockerStackCreateModal = () => { + const ov = Nova.modal('Create Compose Stack', + `
+
+
`, + ` + ` + ); + window.dockerStackCreateSubmit = async () => { + const name = document.getElementById('dsc-name').value.trim(); + const acct = document.getElementById('dsc-acct').value.trim(); + const yaml = document.getElementById('dsc-yaml').value; + if (!name || !yaml) { Nova.toast('Name and YAML required','error'); return; } + ov.remove(); + const r = await Nova.api('docker', 'stack-create', { method: 'POST', body: { name, account_id: acct||null, compose_yaml: yaml } }); + Nova.toast(r?.success ? 'Stack created' : (r?.message||'Failed'), r?.success?'success':'error'); + if (r?.success) dockerLoadTab('stacks'); + }; +}; + +window.dockerQuotaModal = (userId, username) => { + const ov = Nova.modal(`Docker Quota: ${username}`, + `
+
+
`, + ` + ` + ); + window.dockerQuotaSubmit = async (uid) => { + const cnt = parseInt(document.getElementById('dq-cnt').value) || 2; + const mem = parseInt(document.getElementById('dq-mem').value) || 512; + const cpus = parseFloat(document.getElementById('dq-cpus').value) || 1.0; + ov.remove(); + const r = await Nova.api('docker', 'quota-set', { method: 'POST', body: { user_id: uid, max_containers: cnt, max_memory_mb: mem, max_cpus: cpus } }); + Nova.toast(r?.success ? 'Quota saved' : (r?.message||'Failed'), r?.success?'success':'error'); + }; +}; + +// ── #22a-e Server Options ────────────────────────────────────────────────── +async function serverOptions() { + const r = await Nova.api('system', 'server-options'); + const opts = r?.data || {}; + + return ` + + +
+ + +
+
Web Server${Nova.badge(opts.web_server||'apache','green')}
+
+

Current web server for hosting accounts. Changing requires migration of all vhosts.

+
+ + +
+ +
+
+ + +
+
Mail Server${Nova.badge(opts.mail_server||'postfix-dovecot','green')}
+
+

Mail stack for all hosted domains.

+
+ + +
+ +
+
+ + +
+
FTP Server${Nova.badge(opts.ftp_server||'proftpd','green')}
+
+

FTP server for hosting account file transfers.

+
+ + +
+ +
+
+ + +
+
DNS Server${Nova.badge(opts.dns_server||'bind9','green')}
+
+

DNS server for authoritative name service.

+
+ + +
+ +
+
+ +
+ + +
+
+ WHMCS Billing Bridge + ${opts.whmcs_enabled==='1' ? Nova.badge('Enabled','green') : Nova.badge('Disabled','red')} +
+
+

+ Enable the WHMCS provisioning API so WHMCS can create, suspend, unsuspend, and terminate accounts automatically. + Use the API URL below in your WHMCS server module configuration. +

+
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ Nameserver Health + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
`; +} + +window.soSave = async (key, inputId, label) => { + const val = document.getElementById(inputId)?.value; + if (!val) return; + Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, async () => { + Nova.loading(`Switching ${label} to ${val}…`); + const r = await Nova.api('system', 'save-option', { method:'POST', body:{ key, value: val } }); + Nova.loadingDone(); + Nova.toast(r?.success ? `${label} switched to ${val}` : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('server-options'); + }, true); +}; + +window.soSaveWhmcs = async () => { + const key = document.getElementById('so-whmcs-key')?.value?.trim(); + const enabled = document.getElementById('so-whmcs-enabled')?.value; + const r1 = await Nova.api('system', 'save-option', { method:'POST', body:{ key:'whmcs_api_key', value:key } }); + const r2 = await Nova.api('system', 'save-option', { method:'POST', body:{ key:'whmcs_enabled', value:enabled } }); + Nova.toast((r1?.success && r2?.success) ? 'WHMCS settings saved' : 'Save failed', (r1?.success && r2?.success)?'success':'error'); +}; + +window.soSaveNS = async () => { + const ns1 = document.getElementById('so-ns1')?.value?.trim(); + const ns2 = document.getElementById('so-ns2')?.value?.trim(); + await Nova.api('system', 'save-option', { method:'POST', body:{ key:'ns1_hostname', value:ns1 } }); + await Nova.api('system', 'save-option', { method:'POST', body:{ key:'ns2_hostname', value:ns2 } }); + Nova.toast('Nameservers saved', 'success'); +}; + +window.soCheckNS = async () => { + const tc = document.getElementById('so-ns-results'); + if (!tc) return; + tc.innerHTML = '
Checking NS records…
'; + const r = await Nova.api('dns', 'ns-health'); + const results = r?.data?.results || []; + if (!results.length) { tc.innerHTML = '

No zones to check, or DNS manager not configured.

'; return; } + tc.innerHTML = `
+${results.map(z=>` + + + + +`).join('')} +
DomainNS1NS2Status
${Nova.escHtml(z.domain)}${Nova.escHtml(z.ns1||'—')}${Nova.escHtml(z.ns2||'—')}${z.ok ? Nova.badge('OK','green') : Nova.badge('Mismatch','red')}
`; +};