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 @@
| ${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 = `
+Server Status+ +
+
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||'—'}
- `;
+
+ // 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 = `
+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(' / ')} -
-
+ ${hist.length === 0
+ ? 'Uptime -${s.uptime} -No history yet — stats are collected every 5 minutes.
+ `;
+
+ 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 ? `
+
+ ` : '
No audit entries match the current filters.
+ ${Array.from({length: pages}, (_, i) => i + 1).map(p => `
+
+ `).join('')}
+ ` : '';
+
+ const tableCard = `
- Audit Log
-
-
Email Notifications
+
+
+CyberMail Settings
+
+
+
+
+ `;
}
- // ── PHP Manager ────────────────────────────────────────────────────────────
- async function phpManager() {
- return `
-Notification Triggers
+
+
Disk quota and SSL expiry checks run daily via cron.
- `;
- }
+ 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 => `PHP Version Manager
-
-
-Manage installed PHP versions and global extensions. -
- ${['7.4','8.1','8.2','8.3'].map(v => `
-
-
- `).join('')}
- PHP ${v}
- ${Nova.badge('Active','green')}
-
-
-
-
-
- Global PHP Extensions-Extensions installed across all PHP versions: mbstring, curl, gd, xml, zip, opcache, redis, imagick, pdo, pdo_mysql, pdo_pgsql -${r.name} | ${Nova.badge(r.type,'default')} | ${r.value}${r.ttl} |
- ${Nova.escHtml(r.name)} | ${Nova.badge(r.type,'default')} | ${r.ttl||3600} |
+
${Object.entries(svcs).map(([s,st]) => `
-
- ${s}${Nova.badge(st,st==='active'?'green':'red')}
+
@@ -704,34 +1058,47 @@
const res = await Nova.api('ssl', 'list', {params:{account_id:0}});
const certs = res?.data || [];
return `
-
+ ${s}${Nova.badge(st,st==='active'?'green':'red')}
-
-
-
-
+ `).join('')}
+
+
+
+
+
+SSL Certificate Manager
- SSL Certificate Manager
-
+ Certificates
+
- ${certs.length ? `
+
+
+
+
No SSL certificates issued yet. '}
+ No SSL certificates yet. '}
+
+ `;
}
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', `
+ 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. +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 `
- `;
+ };
+
+ const dbTable = dbs.length ? `
+Databases
- ${dbs.length ? `
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 ↗` : ''}
+
No databases yet. ';
+
+ return `
+Database Engine Manager
+ ${engineCard('mysql', 'MySQL', '🐬')}
+ ${engineCard('mariadb', 'MariaDB', '🦭')}
+ ${engineCard('postgresql','PostgreSQL','🐘')}
+
+
+
+
+
+Active EngineUsed for new account databases
+
+
+
+ Currently: ${Nova.badge(actE,'green')}
+
+
+ `;
}
+
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 ? `
All Databases${dbs.length} total
+ ${dbTable}
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')}
-
-
+
+
-
@@ -1338,38 +1843,48 @@ ${ips.length ? `
async function backups() { return backupsFull(); }
// ── Stubs for new pages — implementations in additions block below ─────────
- async function wordpress() { return `ProFTPD uses virtual users stored in 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. 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: ${steps} ` : ''}
+ Backup saved to: NovaCPX is already at the latest commit: ${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 Proxy Manager+
+ ${inst ? `
+
+
+
+ ` : ''}
+
+
+
+
+${!inst ? `
+
+
+ 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
+
+
+
+` : `
+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+
+
+
+
+
+
+
+
+`}`;
+}
+
+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', `
+
+
+ ${hosts.length === 0 ? `
+ Proxy Hosts+ ${hosts.length} total +
+ No proxy hosts yet. Click Sync Accounts to auto-add all hosted domains, or + Add Host to add manually.
+
+ ` : `
+
+
+ `}
+
+
+
+
+ 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', `
+
+
+ `, 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 `
+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. +
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). +
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: +
+
+Session Manager+
+
+
+
+
+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}
+ `;
+}
+
+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 `
+Active Sessions${rows.length} totalNo active sessions '
+ : `
Docker
+ 🐳
+ Docker is not installed+Install Docker CE + Compose on this server to enable container management. + +Docker
+
+Engine ${Nova.escHtml(status.version || '—')} ${status.running ? 'Running' : 'Stopped'} ${Nova.escHtml(d.Type||d.type||'?')} ${Nova.escHtml(d.TotalCount||d.Size||'—')} ${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}
+ ${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')}
+
+
+Loading… 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
+ ${imgs.length} images
+
+
+${imgs.length === 0 ? 'No images ' : `
+
No volumes ' : `
+
No networks ' : `
+
+ ${stacks.length} stacks
+
+
+${stacks.length === 0 ? 'No compose stacks ' : `
+
Set Docker resource limits per user. Click a row to edit. +
${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 `
+Server Options
+
+
+
+
+
+
+
+
+
+ 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. + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+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 = '
+ Nameserver Health
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 = `
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||