/** * 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': dashboard, 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, 'subdomains': window.adminSubdomains, 'parked-domains': window.adminParked, notifications, settings, }; window._novaPages = pages; Nova.initNav(pages); await Nova.loadPage('dashboard', pages); checkUpdates(); // ── Dashboard ────────────────────────────────────────────────────────────── async function dashboard() { const [stats, version, histRes] = await Promise.all([ Nova.api('system', 'stats'), Nova.api('system', 'version'), Nova.api('stats', 'server'), ]); const s = stats?.data || {}; const v = version?.data || {}; const hist = histRes?.data?.history || []; document.getElementById('server-ip').textContent = ''; setTimeout(() => { const canvas = document.getElementById('dash-stats-chart'); if (!canvas || !hist.length) return; const initChart = () => initStatsChart(canvas, hist); if (!window.Chart) { const sc = document.createElement('script'); sc.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'; sc.onload = initChart; document.head.appendChild(sc); } else { initChart(); } }, 150); return `
CPU Usage
${s.cpu?.pct ?? 0}%
Load: ${(s.cpu?.load || [0,0,0]).map(n=>n.toFixed(2)).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||0)-(s.disk?.free||0))} used of ${Nova.bytes(s.disk?.total||0)}
${Nova.progressBar(s.disk?.pct || 0)}
Uptime
${s.uptime || '—'}
PHP ${v.php_version || '—'} · v${v.installed_version || '—'}
Services
${Object.entries(s.services || {}).map(([svc, status]) => ` `).join('')}
${Nova.serviceDot(status)} ${svc} ${Nova.badge(status, status === 'active' ? 'green' : 'red')}
NovaCPX
Version${v.installed_version || '—'}
Branch${v.git_branch || 'main'}
Commit${(v.git_commit||'—').substring(0,8)}${v.git_dirty ? ' dirty' : ''}
PHP${v.php_version || '—'}
OS${v.os || '—'}
24-Hour History ${hist.length} samples
${hist.length === 0 ? '

No history yet — collected every 5 minutes via cron.

' : ''}
`; } 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, maintainAspectRatio:true, interaction:{mode:'index',intersect:false}, plugins:{ legend:{ position:'top', labels:{ color:'#8b90a8', boxWidth:10, font:{size:10} } } }, scales:{ x:{ ticks:{ color:'#8b90a8', maxRotation:0, font:{size:10} }, grid:{ color:'rgba(255,255,255,.05)' } }, y:{ min:0, max:100, ticks:{ color:'#8b90a8', font:{size:10}, callback:v=>v+'%' }, grid:{ color:'rgba(255,255,255,.05)' } } } } }); } // ── 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 `
🐬 phpMyAdmin (MySQL) 🗄️ Adminer (MySQL) 🐘 Adminer (PostgreSQL)
${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`)} ${toolCard('adminer', 'Adminer', '🗄️', `http://${location.hostname}/adminer.php`)}
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 [statsR, domainsR] = await Promise.all([ Nova.api('system','stats'), Nova.api('email','domains'), ]); const svcs = statsR?.data?.services || {}; const mailSvcs = ['postfix','dovecot','rspamd','opendkim'].filter(s => svcs[s]); const domains = domainsR?.data || []; window.msLoadLog = async () => { const r = await Nova.api('system','read-log',{params:{log:'mail'}}); const el = document.getElementById('ms-log-out'); if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; } }; return `
Services
${mailSvcs.length ? mailSvcs.map(s => `
${s} ${Nova.badge(svcs[s],svcs[s]==='active'?'green':'red')}
`).join('') : '

No mail services detected

'}
Mail Queue
Virtual Mail Domains (${domains.length})
${domains.length ? `
${domains.map(d=>``).join('')}
DomainAccountEmail Accounts
${Nova.escHtml(d.domain)}${Nova.escHtml(d.username||'—')}${d.email_count||0}
` : '
No mail domains yet — created automatically when hosting accounts are set up.
'}
Mail Log
← Click Load Log to view recent mail activity
`; } window.adminViewMailQueue = async () => { const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}}); const out = r?.data?.output || 'Queue is empty'; const el = document.getElementById('ms-queue-out'); if (el) el.innerHTML = `
${Nova.escHtml(out)}
`; else Nova.modal('Mail Queue', `
${Nova.escHtml(out)}
`); }; // ── 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'; const ftpR = await Nova.api('ftp','list',{params:{account_id:0}}); const ftpAccts = ftpR?.data || []; return `
${label} ${Nova.badge(status, status==='active'?'green':'red')}
Active server: ${label} — change in Server Options. Passive FTP ports 20/21 · SFTP on port 22.
All FTP Accounts (${ftpAccts.length})
${ftpAccts.length ? `
${ftpAccts.map(f=>``).join('')}
UsernameAccountDirectoryPermissions
${Nova.escHtml(f.username)} ${Nova.escHtml(f.account_domain||String(f.account_id)||'—')} ${Nova.escHtml(f.home_dir||'—')} ${Nova.badge(f.permissions||'rw','blue')}
` : '
No FTP accounts yet — created from 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…
`; } // Refresh without clearing the list first (keeps current content visible while loading) async function dockerLoadTabKeep(tab) { await dockerLoadTab(tab, true); } async function dockerLoadTab(tab, keepContent = false) { const tc = document.getElementById('docker-tab-content'); if (!tc) return; if (!keepContent) 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) => { // Optimistic UI — update the row immediately so user sees feedback const row = document.querySelector(`tr[data-cid="${cid}"]`); if (row) { const badge = row.querySelector('.badge'); if (badge) { badge.textContent = action === 'stop' ? 'stopping…' : action === 'start' ? 'starting…' : 'restarting…'; badge.className = 'badge badge-yellow'; } row.querySelectorAll('button').forEach(b => b.disabled = true); } 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'); // Reload tab to show real status (don't clear first — keep current list visible) if (r?.success || r !== null) dockerLoadTabKeep('containers'); }; window.dockerRemove = (cid) => Nova.confirm('Force remove this container?', async () => { const row = document.querySelector(`tr[data-cid="${cid}"]`); if (row) row.style.opacity = '0.4'; const r = await Nova.api('docker', 'container-remove', { method: 'POST', body: { container_id: cid, force: true } }); Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); if (r?.success || r !== null) dockerLoadTabKeep('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? Stop any containers using it first.', async () => { const r = await Nova.api('docker', 'image-remove', { method: 'POST', body: { image_id: id } }); Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); dockerLoadTabKeep('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
Default Index Page Template

HTML shown when a new hosting account is created. Use {domain} and {username} as placeholders. Leave blank to use the built-in styled template.

`; } 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.soSaveIndexTemplate = async () => { const tpl = document.getElementById('so-index-tpl')?.value || ''; const res = await Nova.api('system','save-option',{method:'POST',body:{key:'default_index_template',value:tpl}}); Nova.toast(res?.success ? 'Default template saved' : 'Save failed', res?.success?'success':'error'); }; 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')}
`; }; // ══ ADMIN SUBDOMAINS PAGE ═════════════════════════════════════════════════ window.adminSubdomains = async function() { document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active')); document.querySelector('[data-page="subdomains"]')?.classList.add('active'); document.getElementById('page-title').textContent = 'All Subdomains'; document.getElementById('page-content').innerHTML = `

All subdomains across all hosting accounts.

Loading…
`; const el = document.getElementById('admin-sub-list'); const res = await Nova.api('accounts','list',{params:{per_page:200}}); if (!res?.success || !res.data?.length) { el.innerHTML='
No accounts
'; return; } let rows = []; for (const acct of res.data) { const dr = await Nova.api('domains','list',{params:{account_id:acct.id}}); if (!dr?.success) continue; dr.data.filter(d=>d.type==='subdomain').forEach(d=>rows.push({...d,acct_username:acct.username})); } if (!rows.length) { el.innerHTML='
No subdomains found.
'; return; } el.innerHTML = ` ${rows.map(d=>``).join('')}
AccountSubdomainSSLCreated
${d.acct_username}${d.domain} ${d.ssl_enabled?Nova.badge('SSL','green'):'—'} ${(d.created_at||'').split('T')[0]}
`; }; // ══ ADMIN PARKED DOMAINS PAGE ═════════════════════════════════════════════ window.adminParked = async function() { document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active')); document.querySelector('[data-page="parked-domains"]')?.classList.add('active'); document.getElementById('page-title').textContent = 'Parked Domains'; document.getElementById('page-content').innerHTML = `

All parked/alias domains across all accounts.

Loading…
`; const el = document.getElementById('admin-park-list'); const res = await Nova.api('accounts','list',{params:{per_page:200}}); if (!res?.success || !res.data?.length) { el.innerHTML='
No accounts
'; return; } let rows = []; for (const acct of res.data) { const dr = await Nova.api('domains','list',{params:{account_id:acct.id}}); if (!dr?.success) continue; const main = dr.data.find(d=>d.type==='main'); dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d=>rows.push({...d,acct_username:acct.username,main_domain:main?.domain||acct.domain})); } if (!rows.length) { el.innerHTML='
No parked domains found.
'; return; } el.innerHTML = ` ${rows.map(d=>``).join('')}
AccountParked DomainPoints ToCreated
${d.acct_username}${d.domain} ${d.main_domain} ${(d.created_at||'').split('T')[0]}
`; };