/** * NovaCPX Admin Panel — page controllers */ (async () => { // ── Auth guard ───────────────────────────────────────────────────────────── // Inline login handler on port 8882 let _loginCredentials = null; const loginForm = document.getElementById('login-form'); if (loginForm) { loginForm.addEventListener('submit', async e => { e.preventDefault(); const btn = document.getElementById('l-btn'); const err = document.getElementById('login-err'); btn.disabled = true; err.style.display = 'none'; // Step 2: TOTP code entry const totpInput = document.getElementById('l-totp'); if (totpInput && _loginCredentials) { btn.textContent = 'Verifying…'; const res = await Nova.api('auth', 'login', { method: 'POST', body: { ..._loginCredentials, totp_code: totpInput.value.trim() } }); if (res?.success && res.data?.user?.role === 'admin') { location.reload(); } else { err.textContent = res?.message || 'Invalid 2FA code'; err.style.display = ''; btn.disabled = false; btn.textContent = 'Verify'; } return; } // Step 1: username + password btn.textContent = 'Signing in…'; const creds = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }; const res = await Nova.api('auth', 'login', { method: 'POST', body: creds }); if (res?.success && res.data?.user?.role === 'admin') { location.reload(); } else if (res?.totp_required) { // Show TOTP step _loginCredentials = creds; document.getElementById('l-user').closest('.form-group').style.display = 'none'; document.getElementById('l-pass').closest('.form-group').style.display = 'none'; const totpGroup = document.createElement('div'); totpGroup.className = 'form-group'; totpGroup.innerHTML = ''; loginForm.insertBefore(totpGroup, btn.parentNode || btn); btn.textContent = 'Verify'; btn.disabled = false; } else { err.textContent = res?.message || 'Invalid credentials or insufficient role'; err.style.display = ''; btn.disabled = false; btn.textContent = 'Sign In to Admin'; } }); } const me = await Nova.api('auth', 'me'); if (!me?.success || me.data.role !== 'admin') { // Already showing the login form in #auth-check return; } document.getElementById('auth-check').style.display = 'none'; document.getElementById('app').style.display = ''; document.getElementById('user-name').textContent = me.data.username; document.getElementById('user-avatar').textContent = me.data.username[0].toUpperCase(); // ── Logout ───────────────────────────────────────────────────────────────── document.getElementById('logout-btn').addEventListener('click', async e => { e.preventDefault(); await Nova.api('auth', 'logout', { method: 'POST' }); location.href = '/'; }); // ── Page definitions ─────────────────────────────────────────────────────── const pages = { dashboard, 'server-status': serverStatus, accounts, resellers, packages, 'create-account': createAccount, 'dns-zones': dnsZones, nameservers, 'web-server': webServer, 'php-manager': phpManager, 'mysql-manager': mysqlManager, 'mail-server': mailServer, 'ftp-server': ftpServer, 'nginx-proxy': nginxProxy, sessions, wordpress, docker, 'ssl-manager': sslManager, firewall, fail2ban, 'audit-log': auditLog, twofa, updates, backups, cloudflare, 'server-options': serverOptions, notifications, settings, }; window._novaPages = pages; Nova.initNav(pages); await Nova.loadPage('dashboard', pages); checkUpdates(); // ── Dashboard ────────────────────────────────────────────────────────────── async function dashboard() { const [stats, version] = await Promise.all([ Nova.api('system', 'stats'), Nova.api('system', 'version'), ]); const s = stats?.data || {}; const v = version?.data || {}; document.getElementById('server-ip').textContent = ''; return `
CPU Usage
${s.cpu?.pct ?? 0}%
Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}
${Nova.progressBar(s.cpu?.pct || 0)}
Memory
${s.ram?.pct ?? 0}%
${Nova.bytes((s.ram?.used_kb||0)*1024)} / ${Nova.bytes((s.ram?.total_kb||0)*1024)}
${Nova.progressBar(s.ram?.pct || 0)}
Disk
${s.disk?.pct ?? 0}%
${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used
${Nova.progressBar(s.disk?.pct || 0)}
Uptime
${s.uptime || '—'}
PHP ${v.php_version || '—'}
Services
${Object.entries(s.services || {}).map(([svc, status]) => ` `).join('')}
${Nova.serviceDot(status)} ${svc} ${Nova.badge(status, status === 'active' ? 'green' : 'red')}
NovaCPX Version
Installed${v.installed_version || '—'}
Branch${v.git_branch || 'main'}
Commit${v.git_commit || '—'}${v.git_dirty ? ' dirty' : ''}
PHP${v.php_version || '—'}
OS${v.os || '—'}
`; } // ── Server Status ────────────────────────────────────────────────────────── async function serverStatus() { 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||'—'}
24-Hour History${hist.length} samples
${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 ──────────────────────────────────────────────────────────────── async function updates(force = false) { const qp = force ? { force: 1 } : {}; const [ver, ncpxCheck, osCheck] = await Promise.all([ Nova.api('system', 'version'), Nova.api('system', 'check-novacpx-update', { params: qp }), Nova.api('system', 'check-os-update', { params: qp }), ]); const v = ver?.data || {}; const ncpx = ncpxCheck?.data || {}; const os = osCheck?.data || {}; const ncpxCount = ncpx.updates_available || 0; const osCount = os.upgradable || 0; const html = `
NovaCPX Panel ${ncpxCount > 0 ? Nova.badge(ncpxCount + ' commit' + (ncpxCount > 1 ? 's' : '') + ' available', 'yellow') : Nova.badge('Up to date', 'green')}

Installed

${v.installed_version || '—'}

Latest (${ncpx.channel || 'stable'})

${ncpx.remote_version || (ncpxCount > 0 ? 'available' : v.installed_version || '—')}

Channel

${Nova.badge(ncpx.channel || 'stable', ncpx.channel === 'beta' ? 'yellow' : 'green')}

PHP

${v.php_version || '—'}
${ncpxCount > 0 ? `
Pending Commits
${ncpx.commits?.map(c => `
${Nova.escHtml(c)}
`).join('') || 'None'}

PHP syntax is validated before deploy. If the panel goes down after update, it will automatically restore from backup.

` : `

NovaCPX is up to date.

`}
Installed Services
Loading service inventory…
Operating System Packages ${os.security_updates > 0 ? Nova.badge(os.security_updates + ' security', 'red') : ''} ${osCount > 0 ? Nova.badge(osCount + ' upgradable', 'yellow') : Nova.badge('All current', 'green')}
${osCount > 0 ? `
${os.packages?.map(p => ``).join('') || ''}
PackageFromTo
${Nova.escHtml(p.name)} ${Nova.escHtml(p.from || '(new)')} ${Nova.escHtml(p.to)}

Services are automatically restarted if an upgrade stops them. The NovaCPX web root is backed up before upgrade and restored if panel ports go down.

` : `

All OS packages are current.

`}
`; setTimeout(loadServiceVersions, 80); return html; } window.forceRefreshUpdates = () => { const content = document.getElementById('page-content'); if (!content) return; content.innerHTML = '
Checking for updates…
'; updates(true).then(html => { if (html) content.innerHTML = html; }); }; window.loadServiceVersions = async () => { const body = document.getElementById('svc-versions-body'); if (!body) return; body.innerHTML = '
Scanning installed services…
'; const r = await Nova.api('system', 'service-versions'); const svcs = r?.data?.services || []; if (!svcs.length) { body.innerHTML = '

No tracked services found.

'; return; } const statusDot = s => s === 'active' ? '● running' : s === null ? '' : '● stopped'; body.innerHTML = `
${svcs.map(s => ``).join('')}
ServiceDescriptionInstalledLatestStatusState
${Nova.escHtml(s.label)}
${Nova.escHtml(s.pkg)}
${Nova.escHtml(s.desc)} ${Nova.escHtml(s.installed)} ${Nova.escHtml(s.latest)} ${s.up_to_date === true ? Nova.badge('current','green') : s.up_to_date === false ? Nova.badge('update available','yellow') : ''} ${statusDot(s.status)}
`; }; // ── Audit Log ────────────────────────────────────────────────────────────── 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 ${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
${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'; }); }; const _phpExtStream = (ver, ext, action) => { const termId = 'phpext-term-' + Date.now(); const verb = action === 'install-extension' ? 'Installing' : 'Removing'; Nova.modal(`${verb} ${ext} (PHP ${ver})`, `
Starting…\n
`, ``); const term = document.getElementById(termId); const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; fetch(`/api/php/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: ver, extension: ext }), credentials: 'same-origin', }).then(resp => { if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; const read = () => reader.read().then(({ done, value }) => { if (done) { append('\n[done]'); return; } buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const obj = JSON.parse(m[1]); if (obj.line) { append(obj.line); } else if (obj.done) { const btn = document.getElementById('phpext-close'); if (btn) { btn.textContent = obj.success ? 'Done ✓' : 'Close'; btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); phpExtModal(ver); }; } } } catch(e) {} } read(); }).catch(err => append(`\n[error: ${err.message}]`)); read(); }).catch(err => append(`\nFetch error: ${err.message}`)); }; window.phpExtInstall = (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; } _phpExtStream(ver, ext, 'install-extension'); }; window.phpExtRemove = (ver, ext) => { Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, () => { _phpExtStream(ver, ext, 'remove-extension'); }, true); }; // ── Notifications (#25) ─────────────────────────────────────────────────── async function notifications() { const res = await Nova.api('system', 'notify-settings'); const s = res?.data || {}; setTimeout(etLoadList, 80); return `
CyberMail Settings
Leave blank to keep existing key. Get your key from platform.cyberpersons.com
Receives alerts for new accounts, suspensions, disk warnings
Email Templates
Loading templates…
`; } 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'); }; // ── Email Template Management ────────────────────────────────────────────── window.etLoadList = async () => { const body = document.getElementById('et-list-body'); if (!body) return; body.innerHTML = '
Loading templates…
'; const r = await Nova.api('system', 'email-templates'); const tmpls = r?.data?.templates || []; if (!tmpls.length) { body.innerHTML = '

No templates found. Create one.

'; return; } body.innerHTML = `
${tmpls.map(t => ``).join('')}
TriggerLabelSubjectStatusActions
${Nova.escHtml(t.trigger_key)} ${Nova.escHtml(t.label)} ${Nova.escHtml(t.subject)} ${t.enabled ? Nova.badge('enabled','green') : Nova.badge('disabled','red')}
`; }; window.etEdit = async (id) => { const r = await Nova.api('system', 'email-template-get', { method: 'POST', body: { id } }); if (!r?.success) { Nova.toast(r?.message || 'Load failed', 'error'); return; } const t = r.data; Nova.modal(id ? `Edit Template: ${t.label}` : 'New Template', `
`, ` ` ); }; window.etNew = () => { Nova.modal('New Email Template', `
`, ` ` ); }; window.etSave = async (id) => { const subject = document.getElementById('et-subject')?.value?.trim(); const body_html = document.getElementById('et-html')?.value?.trim(); const body_text = document.getElementById('et-text')?.value?.trim(); const enabled = document.getElementById('et-enabled')?.value ?? '1'; if (!subject || !body_html) { Nova.toast('Subject and HTML body required', 'error'); return; } const extra = id ? {} : { trigger_key: document.getElementById('et-trigger')?.value?.trim(), label: document.getElementById('et-label')?.value?.trim(), }; const r = await Nova.api('system', 'email-template-save', { method: 'POST', body: { id, subject, body_html, body_text, enabled, ...extra } }); if (r?.success) { Nova.toast('Template saved', 'success'); document.querySelector('.modal-overlay')?.remove(); etLoadList(); } else { Nova.toast(r?.message || 'Save failed', 'error'); } }; window.etDelete = (id, label) => { Nova.confirm(`Delete template "${label}"? This cannot be undone.`, async () => { const r = await Nova.api('system', 'email-template-delete', { method: 'POST', body: { id } }); if (r?.success) { Nova.toast('Template deleted', 'success'); etLoadList(); } else Nova.toast(r?.message || 'Delete failed', 'error'); }, true); }; window.etSendTest = async (id) => { const email = prompt('Send test email to:'); if (!email) return; const r = await Nova.api('system', 'email-template-test', { method: 'POST', body: { id, to: email } }); if (r?.success) Nova.toast(r.message, 'success'); else Nova.toast(r?.message || 'Send failed', 'error'); }; // ── Settings ─────────────────────────────────────────────────────────────── async function settings() { const r = await Nova.api('system', 'server-options'); const o = r?.data || {}; const cur = { panel_name: o.panel_name || 'NovaCPX', default_php: o.default_php || '8.3', ns1: o.default_nameserver1 || '', ns2: o.default_nameserver2 || '', channel: o.update_channel || 'stable', }; const phpOpts = ['7.4','8.1','8.2','8.3'].map(v => ``).join(''); const chanOpts = [ ['stable', 'Stable — major releases (main branch)'], ['beta', 'Beta — minor & patch releases (beta branch)'], ].map(([v, l]) => ``).join(''); return `
Panel Settings
Stable receives major releases pushed to main. Beta tracks the beta branch for minor & patch releases.
`; } window.adminSaveSettings = async () => { const btn = document.querySelector('#settings-form button[type=submit]'); if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; } const saves = [ ['panel_name', document.getElementById('sf-panel-name')?.value?.trim()], ['default_php', document.getElementById('sf-default-php')?.value], ['default_nameserver1',document.getElementById('sf-ns1')?.value?.trim()], ['default_nameserver2',document.getElementById('sf-ns2')?.value?.trim()], ['update_channel', document.getElementById('sf-channel')?.value], ].filter(([, v]) => v != null); let ok = true; for (const [key, value] of saves) { const res = await Nova.api('system', 'save-option', { method: 'POST', body: { key, value } }); if (!res?.success) { ok = false; Nova.toast(`Failed to save ${key}`, 'error'); break; } } if (ok) Nova.toast('Settings saved', 'success'); if (btn) { btn.disabled = false; btn.textContent = 'Save Settings'; } }; // ── Accounts ─────────────────────────────────────────────────────────────── async function accounts() { const res = await Nova.api('accounts', 'list'); const accts = res?.data || []; window._adminAccts = accts; return `
All Hosting Accounts
${renderAccountTable(accts)}
`; } function renderAccountTable(accts) { if (!accts.length) return '
No accounts found.
'; return ` ${accts.map(a => ``).join('')}
UsernameDomainOwnerPackageStatusCreatedActions
${Nova.escHtml(a.username)} ${Nova.escHtml(a.domain)} ${a.reseller_username ? `${Nova.escHtml(a.reseller_username)}` : 'Admin'} ${a.package_name ? Nova.escHtml(a.package_name) : ''} ${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')} ${Nova.relTime(a.created_at)} ${a.status==='active' ? `` : ``}
`; } window.adminLoginAs = async (userId, username) => { Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => { Nova.loading(`Switching to ${username}…`); const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } }); Nova.loadingDone(); if (res?.success) { window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/'; } else { Nova.toast(res?.message || 'Impersonation failed', 'error'); } }); }; window.adminSearchAccounts = async (q) => { const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}}); const el = document.getElementById('admin-acct-table'); if (el) el.innerHTML = renderAccountTable(res?.data || []); }; window.adminSuspend = async (id, user) => { Nova.confirm(`Suspend ${user}?`, async () => { const res = await Nova.api('accounts','suspend',{method:'POST',body:{account_id:id}}); if (res?.success) { Nova.toast('Suspended','success'); adminPage('accounts'); } else Nova.toast(res?.message,'error'); }); }; window.adminUnsuspend = async (id) => { const res = await Nova.api('accounts','unsuspend',{method:'POST',body:{account_id:id}}); if (res?.success) { Nova.toast('Unsuspended','success'); adminPage('accounts'); } else Nova.toast(res?.message,'error'); }; window.adminChangePass = (id, user) => { Nova.modal(`Change Password — ${user}`, `
`, ``); }; window.adminTerminate = (id, user) => { Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, DBs, DNS, email. IRREVERSIBLE.`, async () => { const res = await Nova.api('accounts','terminate',{method:'POST',body:{account_id:id}}); if (res?.success) { Nova.toast('Terminated','success'); adminPage('accounts'); } else Nova.toast(res?.message,'error'); }, true); }; window.adminEditAccount = async (id) => { Nova.loading('Loading account…'); const [acctRes, pkgRes, usersRes, dnsRes] = await Promise.all([ Nova.api('accounts', 'get', { params: { id } }), Nova.api('packages', 'list'), Nova.api('users', 'list', { params: { role: 'reseller' } }), Nova.api('dns', 'zones'), ]); Nova.loadingDone(); if (!acctRes?.success) { Nova.toast(acctRes?.message || 'Failed to load account', 'error'); return; } const a = acctRes.data; const pkgs = pkgRes?.data || []; const resellers = (usersRes?.data || []).filter(u => u.role === 'reseller'); const zone = (dnsRes?.data || []).find(z => z.account_id == id || z.domain === a.domain); const pkgOpts = `` + pkgs.map(p => ``).join(''); const phpOpts = ['8.3','8.2','8.1','7.4'].map(v => ``).join(''); const ownerOpts = `` + resellers.map(r => ``).join(''); Nova.modal(`Edit Account — ${Nova.escHtml(a.username)}`, `
DNS Zone — ${Nova.escHtml(a.domain)}
${zone ? `
Zone ID: ${zone.id}  ·  Serial: ${zone.serial}
` : '
No DNS zone found for this account
'}
`, ` ` ); }; window.adminEditAccountSave = async (id) => { const body = { id, email: document.getElementById('ae-email')?.value?.trim(), reseller_id: document.getElementById('ae-owner')?.value || null, package_id: document.getElementById('ae-pkg')?.value || null, php_version: document.getElementById('ae-php')?.value, ns1: document.getElementById('ae-ns1')?.value?.trim(), ns2: document.getElementById('ae-ns2')?.value?.trim(), }; Nova.loading('Saving account…'); const res = await Nova.api('accounts', 'update', { method: 'POST', body }); Nova.loadingDone(); if (res?.success) { document.querySelector('.modal-overlay')?.remove(); Nova.toast('Account updated', 'success'); adminPage('accounts'); } else { Nova.toast(res?.message || 'Update failed', 'error'); } }; // ── Create Account ───────────────────────────────────────────────────────── async function createAccount() { const pkgRes = await Nova.api('packages', 'list'); const pkgOpts = (pkgRes?.data || []).map(p => ``).join(''); return `
Create Hosting Account
`; } window.adminSubmitCreateAccount = async () => { const res = await Nova.api('accounts','create',{method:'POST',body:{ username: document.getElementById('nca-user')?.value, password: document.getElementById('nca-pass')?.value, email: document.getElementById('nca-email')?.value, domain: document.getElementById('nca-domain')?.value, package_id: document.getElementById('nca-pkg')?.value, php_version:document.getElementById('nca-php')?.value, }}); const el = document.getElementById('nca-result'); if (res?.success) { Nova.toast('Account created!','success'); if (el) el.innerHTML = `
Account created successfully! View accounts →
`; } else { Nova.toast(res?.message || 'Failed','error'); if (el) el.innerHTML = `
${res?.message || 'Error creating account'}
`; } }; // ── Resellers ────────────────────────────────────────────────────────────── async function resellers() { const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }}); const rows = res?.data || []; return `
Reseller Accounts
${rows.length ? ` ${rows.map(r => ``).join('')}
UsernameEmailAccountsStatusActions
${r.username}${r.email||'—'} ${r.account_count||0} ${Nova.badge(r.status,r.status==='active'?'green':'red')}
` : '
No resellers yet.
'}
`; } window.adminAddReseller = () => { Nova.modal('Create Reseller Account', `
`, ``); }; // ── Packages ─────────────────────────────────────────────────────────────── async function packages() { const res = await Nova.api('packages', 'list'); const pkgs = res?.data || []; return `
Hosting Packages
${pkgs.length ? ` ${pkgs.map(p => ``).join('')}
NameDiskBWDBsEmailsPriceAccountsActions
${p.name} ${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'} ${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'} ${p.databases||'∞'} ${p.email_accounts||'∞'} ${p.price ? '$'+p.price : 'Free'} ${p.account_count||0}
` : '
No packages yet. Create one to start hosting accounts.
'}
`; } window.adminAddPkg = () => showAdminPkgModal(); window.adminEditPkg = async (id) => { const r = await Nova.api('packages','get',{params:{id}}); if (r?.success) showAdminPkgModal(r.data); }; function showAdminPkgModal(p = {}) { Nova.modal(p.id ? 'Edit Package' : 'Add Package', `
`, ``); } window.adminSavePkg = async (id) => { const body = {name:document.getElementById('ap-name')?.value,disk_mb:+document.getElementById('ap-disk')?.value,bandwidth_mb:+document.getElementById('ap-bw')?.value,databases:+document.getElementById('ap-db')?.value,email_accounts:+document.getElementById('ap-email')?.value,addon_domains:+document.getElementById('ap-dom')?.value,subdomains:+document.getElementById('ap-sub')?.value,ftp_accounts:+document.getElementById('ap-ftp')?.value,price:+document.getElementById('ap-price')?.value}; const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body}); if (res?.success) { Nova.toast(id?'Updated':'Created','success'); document.querySelector('.modal-overlay')?.remove(); adminPage('packages'); } else Nova.toast(res?.message,'error'); }; window.adminDelPkg = (id, name) => { Nova.confirm(`Delete package "${name}"?`, async () => { const r = await Nova.api('packages','delete',{method:'POST',body:{id}}); if (r?.success) { Nova.toast('Deleted','success'); adminPage('packages'); } else Nova.toast(r?.message,'error'); }, true); }; // ── DNS Zones ────────────────────────────────────────────────────────────── async function dnsZones() { const res = await Nova.api('dns', 'zones'); const zones = res?.data || []; return `
DNS Zones
${zones.length ? ` ${zones.map(z => ``).join('')}
DomainAccountRecordsActions
${z.domain} ${z.username||'—'} ${z.record_count||0}
` : '
No DNS zones yet.
'}
`; } window.adminAddZone = () => { Nova.modal('Create DNS Zone', `
`, ``); }; 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 records = Array.isArray(res.data) ? res.data : []; const rows = records.map(r => `${Nova.escHtml(r.name)}${Nova.badge(r.type,'default')}${Nova.escHtml(r.content||r.value||'')}${r.ttl||3600} `).join(''); Nova.modal(`DNS: ${domain}`, `
${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 () => { const r = await Nova.api('dns','delete-record',{method:'POST',body:{id}}); if (r?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); adminEditZone(zoneId,domain); } else Nova.toast(r?.message,'error'); }, true); }; window.adminDelZone = (id, domain) => { Nova.confirm(`Delete DNS zone for ${domain}?`, async () => { const r = await Nova.api('dns','delete-zone',{method:'POST',body:{zone_id:id}}); if (r?.success) { Nova.toast('Zone deleted','success'); adminPage('dns-zones'); } else Nova.toast(r?.message,'error'); }, true); }; // ── Nameservers ──────────────────────────────────────────────────────────── async function nameservers() { const r = await Nova.api('server_setup','get'); const d = r?.data || {}; return `
Nameserver Configuration
`; } window.adminSaveNS = async () => { const r = await Nova.api('server_setup','nameservers',{method:'POST',body:{ns1:document.getElementById('ns1')?.value,ns2:document.getElementById('ns2')?.value}}); if (r?.success) Nova.toast('Nameservers saved','success'); else Nova.toast(r?.message,'error'); }; window.adminSetHostname = async () => { const r = await Nova.api('server_setup','set-hostname',{method:'POST',body:{hostname:document.getElementById('srvhost')?.value}}); if (r?.success) Nova.toast(`Hostname set to ${r.data?.hostname}`,'success'); else Nova.toast(r?.message,'error'); }; // ── Web Server ──────────────────────────────────────────────────────────── async function webServer() { const r = await Nova.api('system', 'stats'); const svcs = r?.data?.services || {}; const webSvc = Object.keys(svcs).find(k => k.includes('apache') || k.includes('nginx')) || 'apache2'; return `
Web Server Management
${Object.entries(svcs).map(([s,st]) => `
${s}${Nova.badge(st,st==='active'?'green':'red')}
`).join('')}
`; } // ── SSL Manager ──────────────────────────────────────────────────────────── async function sslManager() { const res = await Nova.api('ssl', 'list', {params:{account_id:0}}); const certs = res?.data || []; return `
Certificates
${certs.length ? `
${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
${Nova.escHtml(c.domain)} ${Nova.escHtml(c.username||'—')} ${Nova.badge(c.type||'lets-encrypt','default')} ${c.expires_at||'—'} ${badge}
` : '
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.

`; } const _sslStream = (domain, accountId, label) => { const termId = 'ssl-term-' + Date.now(); Nova.modal(`SSL: ${label || domain}`, `
Requesting certificate…\n
`, ``); const term = document.getElementById(termId); const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; fetch('/api/ssl/issue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, account_id: accountId }), credentials: 'same-origin', }).then(resp => { if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; const read = () => reader.read().then(({ done, value }) => { if (done) { append('\n[done]'); return; } buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const obj = JSON.parse(m[1]); if (obj.line) { append(obj.line); } else if (obj.done) { const btn = document.getElementById('ssl-term-close'); if (btn) { btn.textContent = obj.success ? 'Done ✓' : 'Close'; btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('ssl-manager'); }; } } } catch(e) {} } read(); }).catch(err => append(`\n[error: ${err.message}]`)); read(); }).catch(err => append(`\nFetch error: ${err.message}`)); }; window.adminIssueBulkSSL = async () => { const accts = await Nova.api('accounts','list',{params:{limit:1000}}); const domains = (accts?.data || []).map(a => ({domain: a.domain, id: a.id})); if (!domains.length) { Nova.toast('No accounts found','error'); return; } const termId = 'ssl-bulk-' + Date.now(); Nova.modal('Bulk SSL Issuance', `
Starting bulk SSL for ${domains.length} domains…\n
`, ``); const term = document.getElementById(termId); const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; let done = 0; for (const a of domains) { append(`\n[${++done}/${domains.length}] ${a.domain}…\n`); try { const r = await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain,account_id:a.id}}); append(r?.success ? ` ✓ Issued\n` : ` ✗ ${r?.message || 'failed'}\n`); } catch(e) { append(` ✗ ${e.message}\n`); } } append(`\nDone. ${done} domains processed.\n`); const btn = document.getElementById('ssl-bulk-close'); if (btn) { btn.textContent = 'Done'; btn.className = 'btn btn-primary'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('ssl-manager'); }; } }; window.adminRenewCert = (id) => { Nova.confirm('Renew this SSL certificate now?', async () => { const r = await Nova.api('ssl','renew',{method:'POST',body:{cert_id:id}}); if (r?.success) { Nova.toast('Renewed','success'); adminPage('ssl-manager'); } else Nova.toast(r?.message,'error'); }); }; window.adminIssueSingleSSL = (domain, accountId) => _sslStream(domain, accountId, domain); window.adminDelCert = (id, domain) => { Nova.confirm(`Delete SSL cert for ${domain}?`, async () => { const r = await Nova.api('ssl','delete',{method:'POST',body:{cert_id:id}}); if (r?.success) { Nova.toast('Deleted','success'); adminPage('ssl-manager'); } 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 ─────────────────────────────────────────────────────────────── async function firewall() { const [fwRes, f2bRes, ipRes, ignoreipRes] = await Promise.all([ Nova.api('firewall','status'), Nova.api('firewall','f2b-status'), Nova.api('firewall','ip-lists'), Nova.api('firewall','f2b-ignoreip-list'), ]); const fw = fwRes?.data || {}; const jails = f2bRes?.data?.jails || []; const trusted = ipRes?.data?.trusted || []; const blocked = ipRes?.data?.blocked || []; const fwIgnoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || []; const rules = fw.rules || []; const active = fw.active; const curLogging = fw.logging || 'off'; const totalBanned = jails.reduce((s,j) => s + (j.currently_banned||0), 0); return `
Default Policies

Incoming

Outgoing

Fail2Ban ${totalBanned > 0 ? Nova.badge(totalBanned + ' banned', 'red') : Nova.badge('0 banned','green')}

Active Jails

${jails.length}

Currently Banned

${totalBanned}

UFW Rules ${rules.length} rule${rules.length!==1?'s':''}
${rules.length ? `
${rules.map(r => ``).join('')}
#To / PortActionFrom
${r.num} ${Nova.escHtml(r.to)} ${fwActionBadge(r.action)} ${Nova.escHtml(r.from)}
` : `

No rules defined.

`}
Quick Rule
Trusted IPs ${trusted.length} IPs
${trusted.length ? `
${trusted.map(ip => `${Nova.escHtml(ip)} ×`).join('')}
` : `

No trusted IPs.

`}
Blocked IPs ${blocked.length} IPs
${blocked.length ? `
${blocked.map(ip => `${Nova.escHtml(ip)} ×`).join('')}
` : `

No blocked IPs.

`}
Fail2Ban Jails
${jails.length ? `
${jails.map(j => ``).join('')}
JailCurrently BannedTotal BannedFailedActions
${Nova.escHtml(j.jail)} ${j.currently_banned > 0 ? `${j.currently_banned}` : '0'} ${j.total_banned} ${j.currently_failed} ${j.currently_banned > 0 ? `` : ''}
` : `

Fail2Ban not running or no jails configured.

`}
Fail2Ban Whitelist IPs that will never be banned
${(fwIgnoreips||[]).map(ip => fwIgnoreipChip(ip)).join('')}

Loopback (127.0.0.0/8, ::1) and the server's own LAN IPs are added automatically. Add your home/office IP or subnet here so you never lock yourself out.

UFW Logging
Logs at /var/log/ufw.log
`; } // ── Fail2Ban Manager ──────────────────────────────────────────────────────── async function fail2ban() { const [f2bRes, cfgRes, ignoreipRes] = await Promise.all([ Nova.api('firewall', 'f2b-status'), Nova.api('firewall', 'f2b-config-get'), Nova.api('firewall', 'f2b-ignoreip-list'), ]); const jails = f2bRes?.data?.jails || []; const cfg = cfgRes?.data || { bantime: 3600, findtime: 600, maxretry: 5 }; const ignoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || []; const totalBanned = jails.reduce((s, j) => s + (j.currently_banned || 0), 0); return `
Global Settings
How long IPs stay banned
Window to count failures
Failures before ban
Active Jails ${jails.length} jail${jails.length !== 1 ? 's' : ''}
${jails.length ? `
${jails.map(j => ``).join('')}
JailCurrently BannedTotal BannedFailed
${Nova.escHtml(j.jail)} ${j.currently_banned > 0 ? `${j.currently_banned}` : '0'} ${j.total_banned} ${j.currently_failed} ${j.currently_banned > 0 ? `` : ''}
` : `

Fail2Ban not running or no jails active.

`}
Whitelist (Never Ban) ${ignoreips.length} entr${ignoreips.length !== 1 ? 'ies' : 'y'}
${ignoreips.map(ip => `${Nova.escHtml(ip)} ×`).join('')}

Your own IP/subnet should always be whitelisted.

Log Viewer
Click "Load Log" to view Fail2Ban activity.
`; } window.f2bSaveCfg = async () => { const bantime = document.getElementById('f2b-bantime')?.value; const findtime = document.getElementById('f2b-findtime')?.value; const maxretry = document.getElementById('f2b-maxretry')?.value; const r = await Nova.api('firewall', 'f2b-config-save', { method: 'POST', body: { bantime, findtime, maxretry } }); Nova.toast(r?.message || (r?.success ? 'Saved' : 'Failed'), r?.success ? 'success' : 'error'); }; window.f2bReloadCfg = async () => { const r = await Nova.api('firewall', 'f2b-reload', { method: 'POST' }); Nova.toast(r?.message || (r?.success ? 'Reloaded' : 'Failed'), r?.success ? 'success' : 'error'); }; window.f2bRestartSvc = async () => { const r = await Nova.api('firewall', 'f2b-restart', { method: 'POST' }); Nova.toast(r?.message || (r?.success ? 'Restarted' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('fail2ban'); }; window.f2bViewJail = async (jail) => { const r = await Nova.api('firewall', 'f2b-jail', { method: 'POST', body: { jail } }); const d = r?.data || {}; const ips = d.banned_ips || []; Nova.modal(`Jail: ${jail}`, `

Currently Banned

${d.currently_banned ?? 0}

Total Banned

${d.total_banned ?? 0}

Currently Failed

${d.currently_failed ?? 0}

${ips.length ? ` ${ips.map(ip => ``).join('')}
IP Address
${Nova.escHtml(ip)}
` : '

No IPs currently banned.

'}` ); }; window.f2bBanModal = (jail) => { Nova.modal(`Ban IP in jail: ${jail}`, `
`, `` ); }; window.f2bBanSubmit = async (jail) => { const ip = document.getElementById('f2b-ban-ip')?.value?.trim(); if (!ip) return; document.querySelector('.modal-overlay')?.remove(); const r = await Nova.api('firewall', 'f2b-ban', { method: 'POST', body: { ip, jail } }); Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('fail2ban'); }; window.f2bUnban = async (ip, jail) => { document.querySelector('.modal-overlay')?.remove(); const r = await Nova.api('firewall', 'f2b-unban', { method: 'POST', body: { ip, jail } }); Nova.toast(r?.message || (r?.success ? 'Unbanned' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('fail2ban'); }; window.f2bWhitelistAdd = async () => { const ip = document.getElementById('f2b-ignoreip-input')?.value?.trim(); if (!ip) return; const r = await Nova.api('firewall', 'f2b-ignoreip-add', { method: 'POST', body: { ip } }); Nova.toast(r?.message || (r?.success ? 'Added' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('fail2ban'); }; window.f2bWhitelistRemove = async (ip) => { const r = await Nova.api('firewall', 'f2b-ignoreip-remove', { method: 'POST', body: { ip } }); Nova.toast(r?.message || (r?.success ? 'Removed' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('fail2ban'); }; window.f2bLoadLog = async () => { const lines = document.getElementById('f2b-log-lines')?.value || 100; const el = document.getElementById('f2b-log-content'); if (el) el.innerHTML = 'Loading…'; const r = await Nova.api('firewall', 'f2b-log', { method: 'POST', body: { lines: parseInt(lines) } }); if (el) { if (r?.success && r.data?.log) { // Colorize: NOTICE=green, WARNING=yellow, ERROR/BAN=red, UNBAN=blue const colored = Nova.escHtml(r.data.log) .replace(/(NOTICE)/g, '$1') .replace(/(WARNING)/g, '$1') .replace(/\b(BAN)\b/g, '$1') .replace(/\b(UNBAN)\b/g, '$1') .replace(/(ERROR)/g, '$1'); el.innerHTML = colored; el.scrollTop = el.scrollHeight; } else { el.innerHTML = 'Failed to load log.'; } } }; function fwActionBadge(action) { const a = (action||'').toLowerCase(); if (a.includes('allow')) return Nova.badge('ALLOW','green'); if (a.includes('deny')) return Nova.badge('DENY','red'); if (a.includes('reject'))return Nova.badge('REJECT','red'); if (a.includes('limit')) return Nova.badge('LIMIT','yellow'); return `${Nova.escHtml(action)}`; } window.fwToggle = async (enable) => { const label = enable ? 'Enable' : 'Disable'; Nova.confirm(`${label} UFW firewall?`, async () => { const r = await Nova.api('firewall', enable ? 'enable' : 'disable', {method:'POST'}); Nova.toast(r?.message || label + 'd', r?.success ? 'success' : 'error'); adminPage('firewall'); }, !enable); }; window.fwSavePolicies = async () => { const inc = document.getElementById('pol-incoming')?.value; const out = document.getElementById('pol-outgoing')?.value; await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'incoming',policy:inc}}); const r2 = await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'outgoing',policy:out}}); Nova.toast(r2?.success ? 'Policies saved' : r2?.message, r2?.success ? 'success' : 'error'); adminPage('firewall'); }; window.fwDeleteRule = (num) => { Nova.confirm(`Delete rule #${num}? This cannot be undone.`, async () => { const r = await Nova.api('firewall','delete-rule',{method:'POST',body:{num}}); Nova.toast(r?.message || 'Deleted', r?.success ? 'success' : 'error'); adminPage('firewall'); }, true); }; window.fwResetModal = () => { Nova.confirm('Reset ALL firewall rules to NovaCPX defaults? SSH, HTTP, HTTPS, and panel ports will be re-allowed automatically.', async () => { Nova.toast('Resetting firewall…','info',5000); const r = await Nova.api('firewall','reset',{method:'POST'}); Nova.toast(r?.message || 'Reset complete','success'); adminPage('firewall'); }, true); }; window.fwQuickRule = async () => { const body = { action: document.getElementById('qr-action')?.value, direction: document.getElementById('qr-dir')?.value, port: document.getElementById('qr-port')?.value, proto: document.getElementById('qr-proto')?.value, from_ip: document.getElementById('qr-from')?.value || 'any', comment: document.getElementById('qr-comment')?.value, }; if (!body.port) { Nova.toast('Port/service is required','error'); return; } const r = await Nova.api('firewall','add-rule',{method:'POST',body}); Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('firewall'); }; window.fwAddRuleModal = () => { Nova.modal('Add Firewall Rule',`
`,` `); }; window.fwSubmitAddRule = async () => { const body = { action: document.getElementById('m-action')?.value, direction: document.getElementById('m-dir')?.value, port: document.getElementById('m-port')?.value, proto: document.getElementById('m-proto')?.value, from_ip: document.getElementById('m-from')?.value || 'any', to_ip: document.getElementById('m-to')?.value || 'any', comment: document.getElementById('m-comment')?.value, }; if (!body.port) { Nova.toast('Port is required','error'); return; } document.querySelector('.modal-overlay')?.remove(); const r = await Nova.api('firewall','add-rule',{method:'POST',body}); Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('firewall'); }; window.fwAllowIp = async () => { const ip = document.getElementById('fw-trust-ip')?.value?.trim(); if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; } const r = await Nova.api('firewall','allow-ip',{method:'POST',body:{ip}}); Nova.toast(r?.message || (r?.success ? 'IP allowed' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('firewall'); }; window.fwBlockIp = async () => { const ip = document.getElementById('fw-block-ip')?.value?.trim(); if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; } Nova.confirm(`Block ${ip}? This will deny all incoming traffic from this address.`, async () => { const r = await Nova.api('firewall','block-ip',{method:'POST',body:{ip}}); Nova.toast(r?.message || (r?.success ? 'IP blocked' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('firewall'); }, true); }; window.fwRemoveIp = (ip, action) => { Nova.confirm(`Remove ${action} rule for ${ip}?`, async () => { const r = await Nova.api('firewall','remove-ip',{method:'POST',body:{ip,action}}); Nova.toast(r?.message || 'Removed', r?.success ? 'success' : 'error'); if (r?.success) adminPage('firewall'); }, true); }; window.fwJailDetail = async (jail) => { const r = await Nova.api('firewall','f2b-jail',{method:'POST',body:{jail}}); const d = r?.data || {}; const ips = d.banned_ips || []; Nova.modal(`Fail2Ban: ${jail}`,`

Currently Banned

${d.currently_banned}

Total Banned

${d.total_banned}

${ips.length ? ` ${ips.map(ip=>``).join('')}
Banned IP
${Nova.escHtml(ip)}
` : '

No IPs currently banned in this jail.

'}`); }; window.fwUnbanIp = async (ip, jail, btn) => { if (btn) btn.disabled = true; const r = await Nova.api('firewall','f2b-unban',{method:'POST',body:{ip,jail}}); Nova.toast(r?.message || 'Unbanned', r?.success ? 'success' : 'error'); if (r?.success && btn) btn.closest('tr')?.remove(); }; window.fwManualBanModal = (jail) => { Nova.modal(`Manual Ban — ${jail}`,`
`,` `); }; window.fwSubmitManualBan = async (jail) => { const ip = document.getElementById('mb-ip')?.value?.trim(); if (!ip) { Nova.toast('Enter an IP','error'); return; } document.querySelector('.modal-overlay')?.remove(); const r = await Nova.api('firewall','f2b-ban',{method:'POST',body:{ip,jail}}); Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('firewall'); }; window.fwF2bReload = async () => { const r = await Nova.api('firewall','f2b-reload',{method:'POST'}); Nova.toast(r?.message || 'Reloaded', r?.success ? 'success' : 'error'); }; window.fwF2bRestart = async () => { Nova.confirm('Restart Fail2Ban? Active bans will be preserved.', async () => { const r = await Nova.api('firewall','f2b-restart',{method:'POST'}); Nova.toast(r?.message || 'Restarted', r?.success ? 'success' : 'error'); adminPage('firewall'); }); }; window.fwSetLogging = async () => { const level = document.getElementById('fw-log-level')?.value; const r = await Nova.api('firewall','set-logging',{method:'POST',body:{level}}); if (r?.success) { Nova.toast(`UFW logging set to ${level}`, 'success'); adminPage('firewall'); } else { Nova.toast(r?.message || 'Logging update failed — UFW may need to be enabled first', 'error'); } }; function fwIgnoreipChip(ip) { const isLoopback = ip === '127.0.0.0/8' || ip === '127.0.0.1' || ip === '::1'; return ` ${Nova.escHtml(ip)}${isLoopback ? ' 🔒' : ' ×'} `; } window.fwIgnoreipAdd = async () => { const ip = document.getElementById('fw-ignoreip-input')?.value?.trim(); if (!ip) { Nova.toast('Enter an IP address or CIDR range', 'error'); return; } const r = await Nova.api('firewall','f2b-ignoreip-add',{method:'POST',body:{ip}}); Nova.toast(r?.message || (r?.success ? 'Added' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) { const chips = document.getElementById('ignoreip-chips'); if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join(''); const inp = document.getElementById('fw-ignoreip-input'); if (inp) inp.value = ''; } }; window.fwIgnoreipRemove = async (ip) => { Nova.confirm(`Remove ${ip} from Fail2Ban whitelist? They could get banned if they fail too many login attempts.`, async () => { const r = await Nova.api('firewall','f2b-ignoreip-remove',{method:'POST',body:{ip}}); Nova.toast(r?.message || (r?.success ? 'Removed' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) { const chips = document.getElementById('ignoreip-chips'); if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join(''); } }, true); }; window.fwIgnoreipReset = () => { Nova.confirm('Reset Fail2Ban whitelist to server defaults (loopback + local IPs)?', async () => { const r = await Nova.api('firewall','f2b-ignoreip-reset',{method:'POST'}); Nova.toast(r?.message || 'Reset', r?.success ? 'success' : 'error'); if (r?.success) adminPage('firewall'); }); }; // ── MySQL/DB Manager ─────────────────────────────────────────────────────── async function mysqlManager() { const [engRes, dbRes, toolsRes] = await Promise.all([ Nova.api('system','db-engines'), Nova.api('databases','list',{params:{account_id:0}}), Nova.api('system','db-tools'), ]); const eng = engRes?.data?.engines || {}; const actE = engRes?.data?.active_engine || 'mysql'; const dbs = dbRes?.data || []; const tools = toolsRes?.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 `
${icon} ${label} ${Nova.badge(statusText, statusColor)} ${e.version ? `v${e.version}` : ''}
${!e.installed ? `` : ` ` }
`; }; const toolCard = (id, label, icon, url) => { const t = tools[id] || {}; const statusColor = t.installed ? 'green' : 'default'; const statusText = t.installed ? 'Installed' : 'Not Installed'; return `
${icon} ${label} ${Nova.badge(statusText, statusColor)} ${t.version ? `v${t.version}` : ''}
${!t.installed ? `` : ` Open ↗` }
`; }; 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')}
Database Admin Tools
${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)} ${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)}
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}}); if (r?.success) { Nova.toast('Dropped','success'); adminPage('mysql-manager'); } 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'); }; window.dbToolAction = (tool, action) => { const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' }; const name = names[tool] || tool; const msgs = { install: `Install ${name}?`, reinstall: `Reinstall ${name}? The existing installation will be removed first.`, remove: `Remove ${name}?`, }; // pgAdmin needs an admin account — collect credentials before install/reinstall if (tool === 'pgadmin' && action !== 'remove') { Nova.modal(`${action === 'reinstall' ? 'Reinstall' : 'Install'} pgAdmin 4`, `

pgAdmin requires an admin account to be created on first run.

`, ` `); return; } const openTerminal = (extra = {}) => { document.querySelector('.modal-overlay')?.remove(); const termId = 'dbt-term-' + Date.now(); const verb = action === 'remove' ? 'Removing' : action === 'reinstall' ? 'Reinstalling' : 'Installing'; Nova.modal(`${verb} ${name}`, `
Starting…\n
`, ``); const term = document.getElementById(termId); const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; fetch('/api/system/db-tools-stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool, action, ...extra }), credentials: 'same-origin', }).then(resp => { if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; const read = () => reader.read().then(({ done, value }) => { if (done) { append('\n[stream closed]'); return; } buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const obj = JSON.parse(m[1]); if (obj.line) { append(obj.line); } else if (obj.error) { append(`\n✗ ${obj.error}\n`); } else if (obj.done) { const btn = document.getElementById('dbt-term-close'); if (btn) { btn.textContent = 'Done'; btn.className = 'btn btn-primary'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); }; } } } catch(e) {} } read(); }).catch(err => append(`\n[error: ${err.message}]`)); read(); }).catch(err => append(`\nFetch error: ${err.message}`)); }; Nova.confirm(msgs[action], () => openTerminal(), action === 'remove'); }; window.dbToolRunInstall = (tool, action) => { const email = document.getElementById('pga-email')?.value?.trim(); const pass = document.getElementById('pga-pass')?.value; if (!email) { Nova.toast('Email is required','error'); return; } if (!pass) { Nova.toast('Password is required','error'); return; } // Re-invoke with credentials — openTerminal will close the form modal first const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' }; const name = names[tool] || tool; const msgs = { install: `Install ${name}?`, reinstall: `Reinstall ${name}?` }; const doOpen = () => { document.querySelector('.modal-overlay')?.remove(); const termId = 'dbt-term-' + Date.now(); const verb = action === 'reinstall' ? 'Reinstalling' : 'Installing'; Nova.modal(`${verb} ${name}`, `
Starting…\n
`, ``); const term = document.getElementById(termId); const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; fetch('/api/system/db-tools-stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool, action, pga_email: email, pga_pass: pass }), credentials: 'same-origin', }).then(resp => { if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; const read = () => reader.read().then(({ done, value }) => { if (done) { append('\n[stream closed]'); return; } buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const obj = JSON.parse(m[1]); if (obj.line) { append(obj.line); } else if (obj.error) { append(`\n✗ ${obj.error}\n`); } else if (obj.done) { const btn = document.getElementById('dbt-term-close'); if (btn) { btn.textContent = 'Done'; btn.className = 'btn btn-primary'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); }; } } } catch(e) {} } read(); }).catch(err => append(`\n[error: ${err.message}]`)); read(); }).catch(err => append(`\nFetch error: ${err.message}`)); }; doOpen(); }; // ── Mail Server ──────────────────────────────────────────────────────────── async function mailServer() { const r = await Nova.api('system','stats'); const svcs = r?.data?.services || {}; const mailStatus = svcs['postfix'] || 'unknown'; const doveStatus = svcs['dovecot'] || 'unknown'; return `
Mail Services
${[['postfix',mailStatus],['dovecot',doveStatus]].map(([s,st]) => `
${s} ${Nova.badge(st,st==='active'?'green':'red')}
`).join('')}
Mail Queue
`; } window.adminViewMailQueue = async () => { const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}}); Nova.modal('Mail Queue', `
${r?.data?.output || 'Queue is empty'}
`); }; // ── FTP Server ──────────────────────────────────────────────────────────── async function ftpServer() { 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 (${label}) ${Nova.badge(status, status==='active'?'green':'red')}

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.

`; } // ── Backups — delegates to backupsFull() defined in additions ───────────── async function backups() { return backupsFull(); } // ── Stubs for new pages — implementations in additions block below ───────── 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 () => { Nova.loading('Pulling NovaCPX update from GitHub…'); const res = await Nova.api('system', 'apply-update', { method: 'POST' }); 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.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.', async () => { const startRes = await Nova.api('system', 'apply-os-update', { method: 'POST' }); if (!startRes?.success) { Nova.toast(startRes?.message || 'Failed to start upgrade', 'error'); return; } const jobId = startRes.data.job_id; const ov = Nova.modal('OS Update Progress', `
Starting upgrade…
`, `Running…
` ); const term = document.getElementById('os-term'); const statusEl = document.getElementById('os-upd-status'); const closeBtn = document.getElementById('os-close-btn'); const poll = async () => { const r = await Nova.api('system', 'os-update-status', { params: { job_id: jobId } }); if (!r?.success) { if (term) term.textContent += '\n[Error reading job status]'; if (statusEl) statusEl.textContent = 'Error'; if (closeBtn) closeBtn.disabled = false; return; } if (term) { term.textContent = (r.data.lines || []).join('\n') || 'Waiting for output…'; term.scrollTop = term.scrollHeight; } if (r.data.done) { const ok = r.data.exit_code === 0; if (statusEl) { statusEl.textContent = ok ? 'Complete' : `Failed (exit ${r.data.exit_code})`; statusEl.style.color = ok ? 'var(--success,#22c55e)' : 'var(--error,#ef4444)'; } if (closeBtn) closeBtn.disabled = false; Nova.toast(ok ? 'OS upgrade complete' : 'OS upgrade finished with errors — see log', ok ? 'success' : 'error', 8000); } else { setTimeout(poll, 2000); } }; setTimeout(poll, 2000); }); }; // 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.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`; await window.adminServiceAction(svc, 'restart'); }; // ── Check for updates badge ──────────────────────────────────────────────── async function checkUpdates() { const [ncpx, os] = await Promise.all([ Nova.api('system', 'check-novacpx-update'), Nova.api('system', 'check-os-update'), ]); const ncpxN = ncpx?.data?.updates_available || 0; const osN = os?.data?.upgradable || 0; const total = ncpxN + osN; const badge = document.getElementById('update-badge'); if (badge && total > 0) { badge.textContent = total; badge.style.display = ''; } } })(); // ── ADDITIONS: appended by features #14-17 ──────────────────────────────── // ── WordPress Manager (#14) ──────────────────────────────────────────────── async function wordpressPage() { const [acctRes, wpRes] = await Promise.all([ Nova.api('accounts','list',{params:{limit:500}}), Nova.api('wordpress','list'), ]); const accts = acctRes?.data || []; const installs = wpRes?.data?.installs || []; window._adminAcctsWP = accts; return `
WordPress Installs ${installs.length} install${installs.length!==1?'s':''}
${installs.length ? `
${installs.map(w => ``).join('')}
DomainPathAccountVersionStatusActions
${Nova.escHtml(w.domain)} ${Nova.escHtml(w.path||'/')} ${Nova.escHtml(w.username||'—')} ${w.wp_version ? `${Nova.escHtml(w.wp_version)}` : '—'} ${Nova.badge(w.status||'active', w.status==='active'?'green':w.status==='updating'?'yellow':'red')} ${!w.staging_of ? `` : `staging`}
` : `
No WordPress installs yet. Click "Install WordPress" to get started.
`}
`; } window.wpInstallModal = () => { const accts = window._adminAcctsWP || []; const opts = accts.map(a => ``).join(''); Nova.modal('Install WordPress', `

Install at site root (/) unless you want WordPress at a subdirectory.

wp-cli will be downloaded automatically if not installed. Installation takes 1-2 minutes — a live terminal will show progress.

`, ` `); }; window.wpPathPreset = (sel) => { const pathInput = document.getElementById('wp-path'); if (sel.value === '__custom') { pathInput.style.display = ''; pathInput.value = '/'; pathInput.focus(); } else { pathInput.style.display = 'none'; pathInput.value = sel.value; } }; window.wpSubmitInstall = () => { const acctId = +document.getElementById('wp-acct')?.value; const domain = document.getElementById('wp-domain')?.value?.trim(); const preset = document.getElementById('wp-path-preset')?.value; const path = preset === '__custom' ? (document.getElementById('wp-path')?.value?.trim() || '/') : (preset || '/'); const title = document.getElementById('wp-title')?.value?.trim(); const admin = document.getElementById('wp-admin')?.value?.trim(); const pass = document.getElementById('wp-adminpass')?.value; const email = document.getElementById('wp-email')?.value?.trim(); if (!domain) { Nova.toast('Domain is required','error'); return; } if (!title) { Nova.toast('Site title is required','error'); return; } if (!admin) { Nova.toast('Admin username is required','error'); return; } if (!pass) { Nova.toast('Admin password is required','error'); return; } if (!email) { Nova.toast('Admin email is required','error'); return; } // Close form modal, open terminal modal document.querySelector('.modal-overlay')?.remove(); const termId = 'wp-term-' + Date.now(); Nova.modal('Installing WordPress', `
Connecting to server…\n
`, ``); const term = document.getElementById(termId); const append = (text) => { term.textContent += text; term.scrollTop = term.scrollHeight; }; const body = JSON.stringify({ account_id: acctId, domain, path, site_title: title, admin_user: admin, admin_pass: pass, admin_email: email }); fetch(`/api/wordpress/install-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, credentials: 'same-origin', }).then(resp => { if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; const read = () => reader.read().then(({ done, value }) => { if (done) { append('\n[stream closed]'); return; } buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const obj = JSON.parse(m[1]); if (obj.line) { // Check for __DONE__ sentinel if (obj.line.startsWith('__DONE__')) { try { const result = JSON.parse(obj.line.slice(8)); append(`\n✓ WordPress installed! Admin: ${result.admin_user} | ID #${result.id}\n`); } catch(e) { append('\n✓ WordPress installed!\n'); } } else { append(obj.line); } } else if (obj.error) { append(`\n✗ Error: ${obj.error}\n`); } else if (obj.done) { const cancelBtn = document.getElementById('wp-term-cancel'); if (cancelBtn) { cancelBtn.textContent = 'Done'; cancelBtn.className = 'btn btn-primary'; cancelBtn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('wordpress'); }; } } } catch(e) {} } read(); }).catch(err => append(`\n[connection error: ${err.message}]`)); read(); }).catch(err => append(`\nFetch error: ${err.message}`)); }; window.wpUpdate = async (id, type) => { const action = type === 'core' ? 'update-core' : type === 'plugins' ? 'update-plugins' : 'update-themes'; Nova.toast(`Updating ${type}…`,'info',15000); const r = await Nova.api('wordpress', action, {method:'POST',body:{install_id:id}}); Nova.toast(r?.message || (r?.success ? 'Updated' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('wordpress'); }; window.wpInfo = async (id, domain) => { Nova.toast('Loading info…','info',5000); const r = await Nova.api('wordpress','info',{params:{install_id:id}}); if (!r?.success) { Nova.toast(r?.message,'error'); return; } const d = r.data || {}; const plugins = (d.plugins||[]).map(p => `${Nova.escHtml(p.name)}${Nova.escHtml(p.version||'')}${Nova.badge(p.status||'inactive',p.status==='active'?'green':'muted')}`).join(''); const themes = (d.themes||[]).map(t => `${Nova.escHtml(t.name)}${Nova.escHtml(t.version||'')}${Nova.badge(t.status||'inactive',t.status==='active'?'green':'muted')}`).join(''); Nova.modal(`WordPress: ${domain}`,`

Core Version

${Nova.escHtml(d.version||'—')}

Site URL

${Nova.escHtml(d.siteurl||'—')}

Plugins (${(d.plugins||[]).length})

${plugins ? `${plugins}
PluginVersionStatus
` : '

None

'}

Themes (${(d.themes||[]).length})

${themes ? `${themes}
ThemeVersionStatus
` : '

None

'}`); }; window.wpCloneStaging = (id, domain) => { Nova.confirm(`Clone ${domain} to a staging environment? This copies all files and the database.`, async () => { Nova.toast('Cloning to staging…','info',30000); const r = await Nova.api('wordpress','clone-staging',{method:'POST',body:{install_id:id}}); Nova.toast(r?.message || (r?.success ? 'Staging created' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('wordpress'); }); }; window.wpDelete = (id, domain) => { Nova.confirm(`DELETE WordPress install on ${domain}? This removes all files AND drops the database. IRREVERSIBLE.`, async () => { const r = await Nova.api('wordpress','delete',{method:'POST',body:{install_id:id}}); Nova.toast(r?.message || (r?.success ? 'Deleted' : 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) adminPage('wordpress'); }, true); }; // ── Backup Manager — full implementation (#15) ───────────────────────────── async function backupsFull() { const [acctRes, bkRes] = await Promise.all([ Nova.api('accounts','list',{params:{limit:500}}), Nova.api('backup','list'), ]); const accts = acctRes?.data || []; const backupList = bkRes?.data?.backups || []; const diskUsed = bkRes?.data?.disk_used || 0; window._adminAcctsBK = accts; return `
Total Backups
${backupList.length}
Disk Used
${Nova.bytes(diskUsed)}
Accounts
${accts.length}
Backup Schedules

Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.

${accts.slice(0,8).map(a => ``).join('')} ${accts.length>8?`+${accts.length-8} more`:''}
All Backups
${backupList.length ? `
${backupList.map(b => ``).join('')}
AccountTypeSizeStatusStorageCreatedActions
${Nova.escHtml(b.username||b.account_id||'—')} ${Nova.badge(b.type,'default')} ${Nova.bytes(b.size||0)} ${Nova.badge(b.status, b.status==='complete'?'green':b.status==='failed'?'red':'yellow')} ${b.remote_path ? Nova.badge('remote','blue') : Nova.badge('local','muted')} ${Nova.relTime(b.created_at)} ${b.status==='complete'?`Download`:''}
` : `
No backups yet.
`}
`; } window.bkCreateModal = () => { const accts = window._adminAcctsBK || []; const opts = accts.map(a => ``).join(''); Nova.modal('Create Backup', `
`, ` `); }; window.bkSubmitCreate = async () => { const id = +document.getElementById('bk-acct')?.value; const type = document.getElementById('bk-type')?.value; document.querySelector('.modal-overlay')?.remove(); Nova.toast('Creating backup…','info',30000); const r = await Nova.api('backup','create',{method:'POST',body:{account_id:id,type}}); Nova.toast(r?.message||(r?.success?'Backup complete':'Failed'), r?.success?'success':'error'); if (r?.success) adminPage('backups'); }; window.bkRestore = (id) => { Nova.confirm('Restore this backup? Current files and databases will be overwritten. IRREVERSIBLE.', async () => { Nova.toast('Restoring…','info',30000); const r = await Nova.api('backup','restore',{method:'POST',body:{id}}); Nova.toast(r?.message||(r?.success?'Restored':'Failed'), r?.success?'success':'error'); }, true); }; window.bkDelete = (id) => { Nova.confirm('Delete this backup?', async () => { const r = await Nova.api('backup','delete',{method:'POST',body:{id}}); Nova.toast(r?.message||(r?.success?'Deleted':'Failed'), r?.success?'success':'error'); if (r?.success) adminPage('backups'); }, true); }; window.bkScheduleModal = () => { const accts = window._adminAcctsBK || []; const opts = accts.map(a => ``).join(''); Nova.modal('Configure Backup Schedule', `
`, ` `); }; window.bkScheduleForAccount = async (id, user) => { const r = await Nova.api('backup','get-schedule',{params:{account_id:id}}); const s = r?.data || {}; Nova.modal(`Schedule: ${user}`, `
`, ` `); }; window.bkSaveSchedule = async () => { const id = +document.getElementById('bks-acct')?.value; await bkSaveScheduleFor(id); }; window.bkSaveScheduleFor = async (id) => { const r = await Nova.api('backup','schedule',{method:'POST',body:{ account_id: id, frequency: document.getElementById('bks-freq')?.value, type: document.getElementById('bks-type')?.value, retain: +document.getElementById('bks-retain')?.value, }}); document.querySelector('.modal-overlay')?.remove(); Nova.toast(r?.message||(r?.success?'Schedule saved':'Failed'), r?.success?'success':'error'); }; // ── Cloudflare Integration (#16) ────────────────────────────────────────── async function cloudflarePage() { const acctRes = await Nova.api('accounts','list',{params:{limit:500}}); const accts = acctRes?.data || []; window._adminAcctsCF = accts; return `
Account Credentials

Select an account to configure or view its Cloudflare API key.

`; } window.cfLoadAccount = async (id) => { if (!id) { document.getElementById('cf-acct-panel').innerHTML=''; return; } const r = await Nova.api('cloudflare','get-credentials',{params:{account_id:id}}); const c = r?.data || {}; document.getElementById('cf-acct-panel').innerHTML = `
${c.cf_api_key ? `

Key on file: ${Nova.escHtml(c.cf_api_key)}

` : ''}`; document.getElementById('cf-zones-panel').style.display = ''; window._cfCurrentAcct = id; }; window.cfSaveCredentials = async (id) => { const r = await Nova.api('cloudflare','save-credentials',{method:'POST',body:{ account_id: id, api_key: document.getElementById('cf-apikey')?.value, email: document.getElementById('cf-email')?.value, }}); Nova.toast(r?.message||(r?.success?'Saved':'Failed'), r?.success?'success':'error'); }; window.cfTestKey = async (id) => { const r = await Nova.api('cloudflare','test-key',{method:'POST',body:{ account_id: id, api_key: document.getElementById('cf-apikey')?.value, email: document.getElementById('cf-email')?.value, }}); Nova.toast(r?.message||(r?.data?.valid?'API key is valid':'Invalid key'), r?.data?.valid?'success':'error'); }; window.cfRefreshZones = async () => { const id = window._cfCurrentAcct; if (!id) { Nova.toast('Select an account first','error'); return; } const r = await Nova.api('cloudflare','list-zones',{params:{account_id:id}}); const zones = r?.data?.zones || r?.data || []; const body = document.getElementById('cf-zones-body'); if (!body) return; if (!r?.success) { body.innerHTML=`

${Nova.escHtml(r?.message||'Failed to load zones')}

`; return; } if (!zones.length) { body.innerHTML='

No zones found for these credentials.

'; return; } body.innerHTML = ` ${zones.map(z=>``).join('')}
ZoneStatusPlanActions
${Nova.escHtml(z.name)}
${Nova.escHtml(z.id)}
${Nova.badge(z.status,z.status==='active'?'green':'yellow')} ${Nova.escHtml(z.plan?.name||'—')}
`; }; window.cfViewRecords = async (zoneId, domain, acctId) => { const r = await Nova.api('cloudflare','list-records',{method:'POST',body:{zone_id:zoneId,account_id:acctId}}); const records = r?.data?.records || r?.data || []; Nova.modal(`CF DNS: ${domain}`, !records.length ? '

No records.

' : ` ${records.map(rec=>``).join('')}
NameTypeValueProxy
${Nova.escHtml(rec.name)} ${Nova.badge(rec.type,'default')} ${Nova.escHtml(rec.content)}
`); }; window.cfToggleProxy = async (zoneId, recordId, proxied, acctId) => { const r = await Nova.api('cloudflare','toggle-proxy',{method:'POST',body:{zone_id:zoneId,record_id:recordId,proxied,account_id:acctId}}); Nova.toast(r?.message||(r?.success?'Updated':'Failed'), r?.success?'success':'error'); }; window.cfSync = async (zoneId, domain, dir, acctId) => { const action = dir==='to' ? 'sync-to-cf' : 'sync-from-cf'; const label = dir==='to' ? 'Pushing to Cloudflare' : 'Pulling from Cloudflare'; Nova.toast(`${label}…`,'info',10000); const r = await Nova.api('cloudflare',action,{method:'POST',body:{zone_id:zoneId,domain,account_id:acctId}}); Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error'); }; window.cfPurge = async (zoneId, acctId) => { Nova.confirm('Purge all Cloudflare cache for this zone?', async () => { const r = await Nova.api('cloudflare','purge-cache',{method:'POST',body:{zone_id:zoneId,account_id:acctId}}); Nova.toast(r?.message||(r?.success?'Cache purged':'Failed'), r?.success?'success':'error'); }); }; // ── TOTP / 2FA Admin (#17) ──────────────────────────────────────────────── async function twofaPage() { const res = await Nova.api('accounts','list',{params:{limit:500}}); const users = res?.data || []; return `
User 2FA Status
${users.map(u=>``).join('')}
UsernameEmailRole2FA StatusActions
${Nova.escHtml(u.username)} ${Nova.escHtml(u.email||'—')} ${Nova.badge(u.role||'user','default')}
`; } window.totpCheckStatus = async (userId) => { const r = await Nova.api('totp','admin-status',{method:'POST',body:{user_id:userId}}); const el = document.getElementById(`totp-status-${userId}`); if (!el) return; const enabled = r?.data?.totp_enabled; el.innerHTML = enabled ? Nova.badge('Enabled','green') : Nova.badge('Disabled','muted'); }; window.totpAdminDisable = (userId, username) => { Nova.confirm(`Force-disable 2FA for ${username}? Use only for account recovery when user cannot log in.`, async () => { const r = await Nova.api('totp','admin-disable',{method:'POST',body:{user_id:userId}}); Nova.toast(r?.message||(r?.success?'2FA disabled':'Failed'), r?.success?'success':'error'); if (r?.success) { const el = document.getElementById(`totp-status-${userId}`); if (el) el.innerHTML = Nova.badge('Disabled','muted'); } }, true); }; // ── Nginx Proxy Manager ─────────────────────────────────────────────────────── async function nginxProxyPage() { const [statusR, hostsR, settingsR] = await Promise.all([ Nova.api('proxy', 'status'), Nova.api('proxy', 'hosts'), Nova.api('proxy', 'settings'), ]); const s = statusR?.data || {}; const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); const cfg = settingsR?.data || {}; const run = s.running; const inst = s.installed; const isRemote = cfg.mode === 'remote'; const modeLabel = cfg.mode === 'remote' ? `Remote (${cfg.remote_host || 'unconfigured'})` : (cfg.mode === 'local' ? 'Local' : 'Disabled'); return `
Nginx Status
${cfg.mode === 'disabled' ? 'Disabled' : (run ? 'Running' : 'Stopped')}
${s.version || (inst ? 'nginx' : 'configure in Settings')}
Mode
${modeLabel}
${isRemote ? 'configs pushed via SSH' : (cfg.mode === 'local' ? 'nginx on this VM' : 'click Settings to enable')}
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 || cfg.mode === 'disabled') ? `
🖥

Local Mode

nginx on this server. Apache moves to an internal port. All websites keep working — nginx proxies everything through. One-click setup.

🌐

Remote Proxy VM

Dedicated LXC or VM runs nginx. Panel pushes configs via SSH. Best for production — keeps proxy and hosting isolated.

` : `

Service Controls

${cfg.mode === 'local' ? `` : ''}

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('')}
Domain Upstream SSL Status Actions
${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) => { Nova.loading(action.charAt(0).toUpperCase() + action.slice(1) + 'ing nginx…'); const r = await Nova.api('proxy', 'control', { method: 'POST', body: { action } }); Nova.loadingDone(); const ok = r?.success; const msg = r?.data?.result || r?.message || (ok ? action + ' done' : action + ' failed'); Nova.toast(msg, ok ? 'success' : 'error'); Nova.loadPage('nginx-proxy', window._novaPages); }; 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 = () => { const ov = Nova.modal('Add Proxy Host', `
e.g. http://127.0.0.1:80 or http://10.0.0.2:8080
`, ` ` ); ov.querySelector('#ph-save-btn').addEventListener('click', 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 btn = ov.querySelector('#ph-save-btn'); btn.disabled = true; btn.textContent = 'Adding…'; 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) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } else { btn.disabled = false; btn.textContent = 'Add Host'; } }); }; 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; const ov = Nova.modal('Edit Proxy Host', `
Leave blank to use auto-generated config
`, ` ` ); ov.querySelector('#phe-save-btn').addEventListener('click', async () => { const btn = ov.querySelector('#phe-save-btn'); btn.disabled = true; btn.textContent = 'Saving…'; 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) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } else { btn.disabled = false; btn.textContent = 'Save Changes'; } }); }; 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 () => { Nova.modal('Nginx Proxy Setup Guide', `
Designed for Proxmox (or any Linux hypervisor)
Run NovaCPX on one VM and a lightweight Debian LXC as the nginx proxy. The panel pushes configs and controls nginx via SSH. Works equally well on VMware, AWS, DigitalOcean, bare-metal — see Option C below.

Option A — Proxmox LXC (Recommended)

Create a 512MB Debian 12 LXC on the same Proxmox node. Costs almost no resources.

  1. In Proxmox: Create CT → Debian 12 → 512MB RAM, 8GB disk, same bridge as NovaCPX VM
  2. Boot the LXC, set root password
  3. Go to Settings → set Mode=Remote, enter the LXC IP, root password, and this VM's IP as Backend IP
  4. Click Run Setup on Remote VM — watch live progress
  5. Point your router/firewall port 80/443 to the LXC IP
  6. Click Sync Accounts to auto-populate proxy hosts

Option B — Other hypervisors (VMware, Hyper-V, KVM)

Same flow — any Debian/Ubuntu VM reachable by SSH works.

  1. Create a Debian/Ubuntu VM (1 vCPU, 512MB RAM)
  2. Enable SSH root login: PermitRootLogin yes in /etc/ssh/sshd_config
  3. Install sshpass on the NovaCPX server: apt-get install -y sshpass
  4. Follow steps 3–6 from Option A above

Option C — Cloud / Remote Server (AWS, DigitalOcean, etc.)

NovaCPX pushes configs via public SSH. The proxy VM's public IP handles port 80/443; it forwards to NovaCPX over a private network or VPN.

  1. Provision a small Debian droplet/instance in the same region or with low latency to NovaCPX
  2. Open port 22 (SSH) from NovaCPX's IP only; open 80/443 from anywhere
  3. Set Backend IP to NovaCPX's IP reachable from the cloud proxy (use VPN/private IP if possible)
  4. In Settings: set Remote Host to the cloud server's public IP or hostname
  5. Click Run Setup, then Sync Accounts

Option D — Local nginx on this VM

Not recommended — requires moving Apache off port 80/443 first.

  1. Edit /etc/apache2/ports.conf → change Listen 80 to Listen 8090, restart Apache
  2. Set Settings → Mode = Local, Backend IP = 127.0.0.1
  3. Click Install Nginx Locally
  4. Set upstream http://127.0.0.1:8090 on all proxy hosts
  5. Click Sync Accounts

Settings Reference (Admin → Nginx Proxy → Settings)

FieldDescription
Modedisabled / remote / local
Remote HostIP or hostname of nginx proxy VM (SSH target)
Remote UserSSH user on proxy VM (default: root)
Remote PasswordSSH password (stored encrypted in DB)
Backend IPIP of this NovaCPX Apache — used in auto-generated proxy upstream URLs

How it works

`, null, { cancelLabel: 'Close', showConfirm: false }); }; window.proxySwitchLocal = () => { const slOv = Nova.modal('Enable Local Nginx Proxy', `

Nginx will be installed on this server and take over ports 80/443. Apache moves to an internal port and keeps serving all PHP sites — end users see no change.

What will happen:
1. nginx installed (if not present)
2. Apache moved from port 80 → 8090
3. All existing vhosts updated
4. nginx starts on port 80/443 and proxies to Apache
5. Proxy hosts auto-synced from your accounts
`, ` ` ); slOv.querySelector('#sl-switch-btn').addEventListener('click', () => { slOv.remove(); const port = parseInt(document.getElementById('sl-port')?.value) || 8090; const ov = Nova.modal('Switching to Local Proxy Mode', `

Moving Apache to port ${port} and starting nginx on 80/443…

Starting…\n
`, null, { cancelLabel: 'Close', showConfirm: false }); const log = document.getElementById('proxy-local-log'); let done = false; fetch('/api/proxy/switch-local', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apache_port: port }), }).then(async res => { const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; while (true) { const { value, done: d } = await reader.read(); if (d) break; buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const evt = JSON.parse(m[1]); if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; } if (evt.done) { done = true; log.textContent += '\n— Done.\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1500); } } catch {} } } }).catch(e => { log.textContent += '\n— Connection error: ' + e.message + '\n'; }); ov.querySelector('.modal-close')?.addEventListener('click', () => { done = true; }); }); }; window.proxyDisableLocal = () => { Nova.confirm('Revert to direct Apache mode? nginx will be stopped and Apache will move back to port 80.', () => { const ov = Nova.modal('Disabling Local Proxy Mode', `
Starting…\n
`, null, { cancelLabel: 'Close', showConfirm: false }); const log = document.getElementById('proxy-disable-log'); fetch('/api/proxy/disable-local', { method: 'POST', credentials: 'include' }).then(async res => { const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (m) try { const evt = JSON.parse(m[1]); if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; } if (evt.done) setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1000); } catch {} } } }); }, true); }; window.proxyRunSetup = () => { const ov = Nova.modal('Setting Up Remote Nginx Proxy', `

Running setup on the remote proxy VM — this takes about 30 seconds.

Connecting…\n
`); const log = document.getElementById('proxy-setup-log'); let done = false; fetch('/api/proxy/setup-remote', { method: 'POST', credentials: 'include' }) .then(async res => { if (!res.ok) { log.textContent += '\n— Server error (' + res.status + '). Check remote host settings.\n'; return; } const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; while (true) { const { value, done: d } = await reader.read(); if (d) break; buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const evt = JSON.parse(m[1]); if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; } if (evt.done) { done = true; log.textContent += '\n— Done. Refreshing status…\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1200); } } catch {} } } }) .catch(e => { log.textContent += '\n— Connection error: ' + e.message + '\n'; }); ov.querySelector('.modal-close')?.addEventListener('click', () => { done = true; }); }; window.proxyUninstall = () => { const ov = Nova.modal('Uninstall Nginx Proxy', `

Choose what to remove from the remote proxy VM:


`, ` ` ); ov.querySelector('#uninst-btn').addEventListener('click', async () => { const btn = ov.querySelector('#uninst-btn'); btn.disabled = true; btn.textContent = 'Removing…'; const full = ov.querySelector('input[name="uninst"]:checked')?.value === 'full'; const r = await Nova.api('proxy', 'uninstall', { method: 'DELETE', body: { remove_nginx: full } }); Nova.toast(r?.data?.result || r?.message || 'Done', r?.success ? 'success' : 'error'); if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } else { btn.disabled = false; btn.textContent = 'Uninstall'; } }); }; window.proxySettings = async () => { const r = await Nova.api('proxy', 'settings'); const cfg = r?.data || {}; const ov = Nova.modal('Nginx Proxy Settings', `
`, ` ` ); ov.querySelector('#ps-save-btn').addEventListener('click', async () => { const btn = ov.querySelector('#ps-save-btn'); btn.disabled = true; btn.textContent = 'Saving…'; const mode = document.getElementById('ps-mode')?.value; const pass = document.getElementById('ps-pass')?.value; const body = { mode, remote_host: document.getElementById('ps-host')?.value?.trim() || '', remote_user: document.getElementById('ps-user')?.value?.trim() || 'root', remote_pass: pass || '••••••••', backend_ip: document.getElementById('ps-backend')?.value?.trim() || '', }; const r = await Nova.api('proxy', 'settings', { method: 'POST', body }); Nova.toast(r?.success ? 'Settings saved' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); } else { btn.disabled = false; btn.textContent = 'Save Settings'; } }); }; window.proxyTestRemote = async () => { const host = document.getElementById('ps-host')?.value?.trim(); const user = document.getElementById('ps-user')?.value?.trim() || 'root'; const pass = document.getElementById('ps-pass')?.value; const el = document.getElementById('ps-test-result'); if (!host) { if (el) el.textContent = 'Enter a host first'; return; } if (el) el.textContent = 'Testing…'; // Save current fields temporarily so the test can use them await Nova.api('proxy', 'settings', { method: 'POST', body: { remote_host: host, remote_user: user, remote_pass: pass || '••••••••', }}); const r = await Nova.api('proxy', 'test-remote', { method: 'POST' }); const d = r?.data || {}; if (el) { el.style.color = d.ok ? 'var(--color-success)' : 'var(--color-error)'; el.textContent = d.message || (d.ok ? 'Connected' : 'Failed'); } }; // ── #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('catalog','App Catalog')} ${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 === 'catalog') { const r = await Nova.api('docker', 'catalog'); const catalog = r?.data?.catalog || {}; tc.innerHTML = `

One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.

${Object.entries(catalog).map(([key,app])=>`
${Nova.escHtml(app.icon)}
${Nova.escHtml(app.name)}
${Nova.escHtml(app.description)}
`).join('')}
`; } else if (tab === 'quotas') { const r = await Nova.api('accounts', 'list', { params: { limit: 200 } }); const users = r?.data || []; 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)} 2 512 MB 1.0
`; } } window.dockerAdminLaunchApp = async (preselect) => { const catRes = await Nova.api('docker', 'catalog'); const catalog = catRes?.data?.catalog || {}; const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } }); const accounts = acctRes?.data || []; const appOpts = Object.entries(catalog).map(([k,a])=>``).join(''); const acctOpts = accounts.map(a=>``).join(''); window.dockerAdminUpdateParams = (key) => { const app = catalog[key]; if (!app) return; const tc = document.getElementById('dal-params'); if (!tc) return; tc.innerHTML = (app.params||[]).map(p=>`
`).join(''); }; const ov = Nova.modal('Launch App (Admin)', `
`, ` ` ); dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || ''); window.dockerAdminLaunchSubmit = async () => { const appKey = document.getElementById('dal-app').value; const accountId = parseInt(document.getElementById('dal-account').value); const app = catalog[appKey]; if (!app) return; const params = {}; (app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); }); const missing = (app.params||[]).filter(p => p.required && !params[p.key]); if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; } ov.remove(); Nova.toast(`Launching ${app.name}…`, 'info', 15000); const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } }); Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error'); if (r?.success) dockerLoadTab('stacks'); }; }; 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.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => { Nova.toast('Reinstalling stack…', 'info', 15000); const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } }); Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error'); if (r?.success) dockerLoadTab('stacks'); }, true); 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')}

Controls which server handles customer sites on ports 80/443. The panel itself always runs on Apache (ports 8880–8883) regardless of this setting.

Running status — Apache: ${opts.apache_active ? Nova.badge('active','green') : Nova.badge('inactive','red')}   Nginx: ${opts.nginx_active ? Nova.badge('active','green') : Nova.badge('inactive','red')}

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 = (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.`, () => { const termId = 'so-term-' + Date.now(); Nova.modal(`Switching ${label} to ${val}`, `
Starting…\n
`, ``); const term = document.getElementById(termId); const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; fetch('/api/system/service-switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value: val }), credentials: 'same-origin', }).then(resp => { if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; const read = () => reader.read().then(({ done, value }) => { if (done) { append('\n[stream closed]'); return; } buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { const m = part.match(/^data: (.+)$/m); if (!m) continue; try { const obj = JSON.parse(m[1]); if (obj.line) { append(obj.line); } else if (obj.error) { append(`\n✗ ${obj.error}\n`); } else if (obj.done) { const btn = document.getElementById('so-term-close'); if (btn) { btn.textContent = 'Done'; btn.className = 'btn btn-primary'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('server-options'); }; } } } catch(e) {} } read(); }).catch(err => append(`\n[error: ${err.message}]`)); read(); }).catch(err => append(`\nFetch error: ${err.message}`)); }, 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')}
`; };