/**
* 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 = '2FA Code ';
loginForm.insertBefore(totpGroup, btn.parentNode || btn);
btn.textContent = 'Verify'; btn.disabled = false;
} else {
err.textContent = res?.message || 'Invalid credentials or insufficient role';
err.style.display = '';
btn.disabled = false; btn.textContent = 'Sign In to Admin';
}
});
}
const me = await Nova.api('auth', 'me');
if (!me?.success || me.data.role !== 'admin') {
// Already showing the login form in #auth-check
return;
}
document.getElementById('auth-check').style.display = 'none';
document.getElementById('app').style.display = '';
document.getElementById('user-name').textContent = me.data.username;
document.getElementById('user-avatar').textContent = me.data.username[0].toUpperCase();
// ── Logout ─────────────────────────────────────────────────────────────────
document.getElementById('logout-btn').addEventListener('click', async e => {
e.preventDefault();
await Nova.api('auth', 'logout', { method: 'POST' });
location.href = '/';
});
// ── Page definitions ───────────────────────────────────────────────────────
const pages = {
dashboard,
'server-status': serverStatus,
accounts,
resellers,
packages,
'create-account': createAccount,
'dns-zones': dnsZones,
nameservers,
'web-server': webServer,
'php-manager': phpManager,
'mysql-manager': mysqlManager,
'mail-server': mailServer,
'ftp-server': ftpServer,
'nginx-proxy': nginxProxy,
sessions,
wordpress,
docker,
'ssl-manager': sslManager,
firewall,
'audit-log': auditLog,
twofa,
updates,
backups,
cloudflare,
'server-options': serverOptions,
notifications,
settings,
};
window._novaPages = pages;
Nova.initNav(pages);
await Nova.loadPage('dashboard', pages);
checkUpdates();
// ── Dashboard ──────────────────────────────────────────────────────────────
async function dashboard() {
const [stats, version] = await Promise.all([
Nova.api('system', 'stats'),
Nova.api('system', 'version'),
]);
const s = stats?.data || {};
const v = version?.data || {};
document.getElementById('server-ip').textContent = '';
return `
CPU Usage
${s.cpu?.pct ?? 0}%
Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}
${Nova.progressBar(s.cpu?.pct || 0)}
Memory
${s.ram?.pct ?? 0}%
${Nova.bytes((s.ram?.used_kb||0)*1024)} / ${Nova.bytes((s.ram?.total_kb||0)*1024)}
${Nova.progressBar(s.ram?.pct || 0)}
Disk
${s.disk?.pct ?? 0}%
${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used
${Nova.progressBar(s.disk?.pct || 0)}
Uptime
${s.uptime || '—'}
PHP ${v.php_version || '—'}
${Object.entries(s.services || {}).map(([svc, status]) => `
${Nova.serviceDot(status)} ${svc}
${Nova.badge(status, status === 'active' ? 'green' : 'red')}
Restart
Stop
`).join('')}
Installed ${v.installed_version || '—'}
Branch ${v.git_branch || 'main'}
Commit ${v.git_commit || '—'}${v.git_dirty ? ' dirty ' : ''}
PHP ${v.php_version || '—'}
OS ${v.os || '—'}
Check for Updates
`;
}
// ── Server Status ──────────────────────────────────────────────────────────
async function serverStatus() {
const [liveRes, histRes] = await Promise.all([
Nova.api('system', 'stats'),
Nova.api('stats', 'server'),
]);
const s = liveRes?.data || {};
const hist = histRes?.data?.history || [];
const html = `
CPU
${s.cpu?.pct??0}%
${Nova.progressBar(s.cpu?.pct||0)}
RAM
${s.ram?.pct??0}%
${Nova.progressBar(s.ram?.pct||0)}
Disk
${s.disk?.pct??0}%
${Nova.progressBar(s.disk?.pct||0)}
Load Avg
${(s.cpu?.load||[0]).map(v=>v.toFixed(2)).join(' / ')}
Uptime: ${s.uptime||'—'}
${hist.length === 0
? '
No history yet — stats are collected every 5 minutes. Check that the collector cron is running: */5 * * * * root /usr/bin/php /opt/novacpx/bin/collect-stats.php
'
: '
'}
`;
// Can't return html and async render chart — use a trick: render then init chart
setTimeout(() => {
const canvas = document.getElementById('stats-chart');
if (!canvas || !hist.length) return;
if (!window.Chart) {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';
s.onload = () => initStatsChart(canvas, hist);
document.head.appendChild(s);
} else {
initStatsChart(canvas, hist);
}
}, 100);
return html;
}
function initStatsChart(canvas, hist) {
const labels = hist.map(r => {
const d = new Date(r.recorded_at);
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
});
const step = Math.max(1, Math.floor(labels.length / 24));
const sparse = labels.map((l,i) => i % step === 0 ? l : '');
new Chart(canvas, {
type: 'line',
data: {
labels: sparse,
datasets: [
{ label: 'CPU %', data: hist.map(r=>parseFloat(r.cpu_usage||0)), borderColor:'#6366f1', backgroundColor:'rgba(99,102,241,.1)', tension:.3, pointRadius:0, fill:true },
{ label: 'RAM %', data: hist.map(r=>parseFloat(r.ram_usage||0)), borderColor:'#0ea5e9', backgroundColor:'rgba(14,165,233,.1)', tension:.3, pointRadius:0, fill:true },
{ label: 'Disk %', data: hist.map(r=>parseFloat(r.disk_usage||0)), borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,.08)', tension:.3, pointRadius:0, fill:true },
],
},
options: {
responsive: true,
animation: false,
interaction: { mode:'index', intersect:false },
scales: {
x: { grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', maxRotation:0 } },
y: { min:0, max:100, grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', callback: v=>v+'%' } },
},
plugins: {
legend: { labels:{ color:'#e2e4f0', font:{ size:12 } } },
tooltip: { callbacks:{ label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%` } },
},
},
});
}
// ── Updates ────────────────────────────────────────────────────────────────
async function updates() {
const [ver, ncpxCheck, osCheck] = await Promise.all([
Nova.api('system', 'version'),
Nova.api('system', 'check-novacpx-update'),
Nova.api('system', 'check-os-update'),
]);
const v = ver?.data || {};
const ncpx = ncpxCheck?.data || {};
const os = osCheck?.data || {};
const ncpxCount = ncpx.updates_available || 0;
const osCount = os.upgradable || 0;
return `
Installed
${v.installed_version || '—'}
Commit
${ncpx.current_commit || v.git_commit || '—'}
Branch
${ncpx.branch || 'main'}
PHP
${v.php_version || '—'}
${ncpxCount > 0 ? `
${ncpx.commits?.map(c => `
${Nova.escHtml(c)}
`).join('') || 'None'}
PHP syntax is validated before deploy. If the panel goes down after update, it will automatically restore from backup.
Update NovaCPX
` : `
NovaCPX is up to date.
`}
${osCount > 0 ? `
Package From To
${os.packages?.map(p => `
${Nova.escHtml(p.name)}
${Nova.escHtml(p.from || '(new)')}
${Nova.escHtml(p.to)}
`).join('') || ''}
Services are automatically restarted if an upgrade stops them. The NovaCPX web root is backed up before upgrade and restored if panel ports go down.
Apply OS Upgrade
` : `
All OS packages are current.
`}
`;
}
// ── Audit Log ──────────────────────────────────────────────────────────────
async function auditLog(opts = {}) {
const { page = 1, user = '', action = '', date_from = '', date_to = '' } = opts;
const params = { page, per_page: 50 };
if (user) params.user = user;
if (action) params.action = action;
if (date_from) params.date_from = date_from;
if (date_to) params.date_to = date_to;
const content = document.getElementById('page-content');
const filterBar = `
`;
if (content) content.innerHTML = filterBar + 'Loading…
';
const res = await Nova.api('system', 'audit-log', { params });
const rows = res?.data || [];
const meta = res?.meta || {};
const total = meta.total || rows.length;
const pages = meta.pages || 1;
const tableHtml = rows.length ? `
Time User Action Resource IP
${rows.map((r, i) => `
${Nova.relTime(r.created_at)}
${Nova.escHtml(r.username || '—')}
${Nova.escHtml(r.action)}
${Nova.escHtml(r.resource || '—')}
${Nova.escHtml(r.ip_address || '—')}
▾
${
r.detail ? JSON.stringify(JSON.parse(r.detail || '{}'), null, 2) : '(no detail)'
}
`).join('')}
` : 'No audit entries match the current filters.
';
const paginationHtml = pages > 1 ? `
${Array.from({length: pages}, (_, i) => i + 1).map(p => `
${p}
`).join('')}
` : '';
const tableCard = `
${tableHtml}
${paginationHtml}
`;
if (content) content.innerHTML = filterBar + tableCard;
else return filterBar + tableCard;
window._alOpts = opts;
}
window.alToggleDetail = (i) => {
const row = document.getElementById('al-detail-' + i);
if (row) row.style.display = row.style.display === 'none' ? '' : 'none';
};
window.alApplyFilter = () => {
auditLog({
page: 1,
user: document.getElementById('al-user')?.value || '',
action: document.getElementById('al-action')?.value || '',
date_from: document.getElementById('al-from')?.value || '',
date_to: document.getElementById('al-to')?.value || '',
});
};
window.alGoPage = (p) => auditLog({ ...(window._alOpts || {}), page: p });
// ── PHP Manager ────────────────────────────────────────────────────────────
async function phpManager() {
const res = await Nova.api('php', 'versions');
const data = res?.data || {};
const vers = data.versions || [];
const panelPhp = data.panel_php || '—';
return `
NovaCPX itself runs on PHP ${panelPhp} (always the highest installed version, updated automatically when a new version is installed).
${vers.map(v => `
PHP ${v.version}
${v.installed ? Nova.badge(v.fpm_active ? 'active' : 'stopped', v.fpm_active ? 'green' : 'yellow') : Nova.badge('not installed','muted')}
${v.is_default ? `
Panel default
` : ''}
${v.installed ? `
Extensions
Restart FPM
${!v.is_default ? `Remove ` : ''}
` : `
Install
`}
`).join('')}
`;
}
window.phpInstallVersion = (ver) => {
Nova.confirm(`Install PHP ${ver}? This will run apt-get and may take a minute.`, async () => {
Nova.loading(`Installing PHP ${ver}…`);
const r = await Nova.api('php', 'install-version', { method: 'POST', body: { version: ver } });
Nova.loadingDone();
if (r?.success) { Nova.toast(`PHP ${ver} installed`, 'success'); adminPage('php-manager'); }
else Nova.toast(r?.message || 'Install failed', 'error');
});
};
window.phpRemoveVersion = (ver) => {
Nova.confirm(`Remove PHP ${ver}? All FPM pools for this version will stop.`, async () => {
Nova.loading(`Removing PHP ${ver}…`);
const r = await Nova.api('php', 'remove-version', { method: 'POST', body: { version: ver } });
Nova.loadingDone();
if (r?.success) { Nova.toast(`PHP ${ver} removed`, 'success'); adminPage('php-manager'); }
else Nova.toast(r?.message || 'Remove failed', 'error');
}, true);
};
window.phpFpmAction = async (ver, cmd) => {
Nova.loading(`${cmd} php${ver}-fpm…`);
const r = await Nova.api('php', 'fpm-action', { method: 'POST', body: { version: ver, command: cmd } });
Nova.loadingDone();
if (r?.success) { Nova.toast(r.message, 'success'); refreshSvcStatus(`php${ver}-fpm`); }
else Nova.toast(r?.message || 'Action failed', 'error');
};
window.phpExtModal = async (ver) => {
const panel = document.getElementById('php-ext-panel');
if (!panel) return;
panel.style.display = '';
panel.innerHTML = `Loading extensions for PHP ${ver}…
`;
panel.scrollIntoView({ behavior: 'smooth' });
const r = await Nova.api('php', 'version-extensions', { params: { version: ver } });
if (!r?.success) { panel.innerHTML = `${r?.message || 'Failed to load'}
`; return; }
const installed = r.data.installed || [];
const available = r.data.available || [];
const notInstalled = available.filter(pkg => {
const ext = pkg.replace(/^php[\d.]+-/, '');
return !installed.some(i => i.toLowerCase() === ext.toLowerCase() || i.toLowerCase().replace('_','-') === ext.toLowerCase());
});
panel.innerHTML = `
Extension Action
${installed.map(e => `
${e}
Remove
`).join('')}
`;
};
window.phpExtFilter = (q) => {
document.querySelectorAll('.php-ext-row').forEach(row => {
row.style.display = row.dataset.ext.includes(q.toLowerCase()) ? '' : 'none';
});
};
window.phpExtInstall = async (ver) => {
const sel = document.getElementById('php-ext-add-sel')?.value;
const custom = document.getElementById('php-ext-add-custom')?.value?.trim();
const ext = custom || sel;
if (!ext) { Nova.toast('Choose or type an extension name', 'error'); return; }
Nova.toast(`Installing ${ext} for PHP ${ver}…`, 'info', 15000);
const r = await Nova.api('php', 'install-extension', { method: 'POST', body: { version: ver, extension: ext } });
if (r?.success) { Nova.toast(r.message, 'success'); phpExtModal(ver); }
else Nova.toast(r?.message || 'Install failed', 'error');
};
window.phpExtRemove = (ver, ext) => {
Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, async () => {
const r = await Nova.api('php', 'remove-extension', { method: 'POST', body: { version: ver, extension: ext } });
if (r?.success) { Nova.toast(r.message, 'success'); phpExtModal(ver); }
else Nova.toast(r?.message || 'Remove failed', 'error');
}, true);
};
// ── Notifications (#25) ───────────────────────────────────────────────────
async function notifications() {
const res = await Nova.api('system', 'notify-settings');
const s = res?.data || {};
return `
Event Recipient Notes
Account Created New user + Admin Sends welcome email with credentials
Account Suspended Account holder + Admin Includes suspension reason
Disk Quota ≥85% Account holder + Admin Once per day per account (cron)
SSL Expiry ≤14 days Account holder + Admin Once per threshold per domain (cron)
Disk quota and SSL expiry checks run daily via cron.
`;
}
document.addEventListener('submit', async e => {
if (!e.target.matches('#notify-form')) return;
e.preventDefault();
const fd = new FormData(e.target);
const body = Object.fromEntries(fd.entries());
if (!body.cybermail_api_key) delete body.cybermail_api_key;
const res = await Nova.api('system', 'save-notify-settings', { method: 'POST', body });
if (res?.success) Nova.toast('Notification settings saved', 'success');
else Nova.toast(res?.message || 'Save failed', 'error');
});
window.notifyTest = async () => {
const email = prompt('Send test email to:');
if (!email) return;
const res = await Nova.api('system', 'test-notify', { method: 'POST', body: { to: email } });
if (res?.success) Nova.toast(res.message, 'success');
else Nova.toast(res?.message || 'Send failed', 'error');
};
// ── Settings ───────────────────────────────────────────────────────────────
async function settings() {
return `
`;
}
// ── Accounts ───────────────────────────────────────────────────────────────
async function accounts() {
const res = await Nova.api('accounts', 'list');
const accts = res?.data || [];
window._adminAccts = accts;
return `
${renderAccountTable(accts)}
`;
}
function renderAccountTable(accts) {
if (!accts.length) return 'No accounts found.
';
return `Username Domain Owner Package Status Created Actions
${accts.map(a => `
${Nova.escHtml(a.username)}
${Nova.escHtml(a.domain)}
${a.reseller_username ? `${Nova.escHtml(a.reseller_username)} ` : 'Admin '}
${a.package_name ? Nova.escHtml(a.package_name) : '— '}
${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}
${Nova.relTime(a.created_at)}
Login As
Edit
${a.status==='active'
? `Suspend `
: `Unsuspend `}
Passwd
Terminate
`).join('')}
`;
}
window.adminLoginAs = async (userId, username) => {
Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => {
Nova.loading(`Switching to ${username}…`);
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
Nova.loadingDone();
if (res?.success) {
window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
} else {
Nova.toast(res?.message || 'Impersonation failed', 'error');
}
});
};
window.adminSearchAccounts = async (q) => {
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
const el = document.getElementById('admin-acct-table');
if (el) el.innerHTML = renderAccountTable(res?.data || []);
};
window.adminSuspend = async (id, user) => {
Nova.confirm(`Suspend ${user}?`, async () => {
const res = await Nova.api('accounts','suspend',{method:'POST',body:{account_id:id}});
if (res?.success) { Nova.toast('Suspended','success'); adminPage('accounts'); }
else Nova.toast(res?.message,'error');
});
};
window.adminUnsuspend = async (id) => {
const res = await Nova.api('accounts','unsuspend',{method:'POST',body:{account_id:id}});
if (res?.success) { Nova.toast('Unsuspended','success'); adminPage('accounts'); }
else Nova.toast(res?.message,'error');
};
window.adminChangePass = (id, user) => {
Nova.modal(`Change Password — ${user}`, `New Password
`,
`Update `);
};
window.adminTerminate = (id, user) => {
Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, DBs, DNS, email. IRREVERSIBLE.`, async () => {
const res = await Nova.api('accounts','terminate',{method:'POST',body:{account_id:id}});
if (res?.success) { Nova.toast('Terminated','success'); adminPage('accounts'); }
else Nova.toast(res?.message,'error');
}, true);
};
window.adminEditAccount = async (id) => {
Nova.loading('Loading account…');
const [acctRes, pkgRes, usersRes, dnsRes] = await Promise.all([
Nova.api('accounts', 'get', { params: { id } }),
Nova.api('packages', 'list'),
Nova.api('users', 'list', { params: { role: 'reseller' } }),
Nova.api('dns', 'zones'),
]);
Nova.loadingDone();
if (!acctRes?.success) { Nova.toast(acctRes?.message || 'Failed to load account', 'error'); return; }
const a = acctRes.data;
const pkgs = pkgRes?.data || [];
const resellers = (usersRes?.data || []).filter(u => u.role === 'reseller');
const zone = (dnsRes?.data || []).find(z => z.account_id == id || z.domain === a.domain);
const pkgOpts = `— No package — ` +
pkgs.map(p => `${Nova.escHtml(p.name)} `).join('');
const phpOpts = ['8.3','8.2','8.1','7.4'].map(v =>
`PHP ${v} `).join('');
const ownerOpts = `— Admin (no reseller) — ` +
resellers.map(r => `${Nova.escHtml(r.username)} `).join('');
Nova.modal(`Edit Account — ${Nova.escHtml(a.username)}`,
`
DNS Zone — ${Nova.escHtml(a.domain)}
${zone ? `
Zone ID: ${zone.id} · Serial: ${zone.serial}
` : '
No DNS zone found for this account
'}
`,
`Cancel
Save Changes `
);
};
window.adminEditAccountSave = async (id) => {
const body = {
id,
email: document.getElementById('ae-email')?.value?.trim(),
reseller_id: document.getElementById('ae-owner')?.value || null,
package_id: document.getElementById('ae-pkg')?.value || null,
php_version: document.getElementById('ae-php')?.value,
ns1: document.getElementById('ae-ns1')?.value?.trim(),
ns2: document.getElementById('ae-ns2')?.value?.trim(),
};
Nova.loading('Saving account…');
const res = await Nova.api('accounts', 'update', { method: 'POST', body });
Nova.loadingDone();
if (res?.success) {
document.querySelector('.modal-overlay')?.remove();
Nova.toast('Account updated', 'success');
adminPage('accounts');
} else {
Nova.toast(res?.message || 'Update failed', 'error');
}
};
// ── Create Account ─────────────────────────────────────────────────────────
async function createAccount() {
const pkgRes = await Nova.api('packages', 'list');
const pkgOpts = (pkgRes?.data || []).map(p => `${p.name} — ${p.disk_mb}MB `).join('');
return `
`;
}
window.adminSubmitCreateAccount = async () => {
const res = await Nova.api('accounts','create',{method:'POST',body:{
username: document.getElementById('nca-user')?.value,
password: document.getElementById('nca-pass')?.value,
email: document.getElementById('nca-email')?.value,
domain: document.getElementById('nca-domain')?.value,
package_id: document.getElementById('nca-pkg')?.value,
php_version:document.getElementById('nca-php')?.value,
}});
const el = document.getElementById('nca-result');
if (res?.success) {
Nova.toast('Account created!','success');
if (el) el.innerHTML = ``;
} else {
Nova.toast(res?.message || 'Failed','error');
if (el) el.innerHTML = `${res?.message || 'Error creating account'}
`;
}
};
// ── Resellers ──────────────────────────────────────────────────────────────
async function resellers() {
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
const rows = res?.data || [];
return `
${rows.length ? `
Username Email Accounts Status Actions
${rows.map(r => `
${r.username} ${r.email||'—'}
${r.account_count||0}
${Nova.badge(r.status,r.status==='active'?'green':'red')}
Passwd
Suspend
`).join('')}
`
: '
No resellers yet.
'}
`;
}
window.adminAddReseller = () => {
Nova.modal('Create Reseller Account', `
Username
Password
Email
`,
`Create `);
};
// ── Packages ───────────────────────────────────────────────────────────────
async function packages() {
const res = await Nova.api('packages', 'list');
const pkgs = res?.data || [];
return `
${pkgs.length ? `
Name Disk BW DBs Emails Price Accounts Actions
${pkgs.map(p => `
${p.name}
${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'}
${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'}
${p.databases||'∞'}
${p.email_accounts||'∞'}
${p.price ? '$'+p.price : 'Free'}
${p.account_count||0}
Edit
Del
`).join('')}
`
: '
No packages yet. Create one to start hosting accounts.
'}
`;
}
window.adminAddPkg = () => showAdminPkgModal();
window.adminEditPkg = async (id) => {
const r = await Nova.api('packages','get',{params:{id}});
if (r?.success) showAdminPkgModal(r.data);
};
function showAdminPkgModal(p = {}) {
Nova.modal(p.id ? 'Edit Package' : 'Add Package', `
`,
`Save `);
}
window.adminSavePkg = async (id) => {
const body = {name:document.getElementById('ap-name')?.value,disk_mb:+document.getElementById('ap-disk')?.value,bandwidth_mb:+document.getElementById('ap-bw')?.value,databases:+document.getElementById('ap-db')?.value,email_accounts:+document.getElementById('ap-email')?.value,addon_domains:+document.getElementById('ap-dom')?.value,subdomains:+document.getElementById('ap-sub')?.value,ftp_accounts:+document.getElementById('ap-ftp')?.value,price:+document.getElementById('ap-price')?.value};
const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body});
if (res?.success) { Nova.toast(id?'Updated':'Created','success'); document.querySelector('.modal-overlay')?.remove(); adminPage('packages'); }
else Nova.toast(res?.message,'error');
};
window.adminDelPkg = (id, name) => {
Nova.confirm(`Delete package "${name}"?`, async () => {
const r = await Nova.api('packages','delete',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Deleted','success'); adminPage('packages'); }
else Nova.toast(r?.message,'error');
}, true);
};
// ── DNS Zones ──────────────────────────────────────────────────────────────
async function dnsZones() {
const res = await Nova.api('dns', 'zones');
const zones = res?.data || [];
return `
${zones.length ? `
Domain Account Records Actions
${zones.map(z => `
${z.domain}
${z.username||'—'}
${z.record_count||0}
Records
Del
`).join('')}
`
: '
No DNS zones yet.
'}
`;
}
window.adminAddZone = () => {
Nova.modal('Create DNS Zone', `Domain
`,
`Create `);
};
window.adminEditZone = async (id, domain) => {
const res = await Nova.api('dns', 'records', {params:{zone_id:id}});
if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
const records = Array.isArray(res.data) ? res.data : [];
const rows = records.map(r => `${Nova.escHtml(r.name)} ${Nova.badge(r.type,'default')} ${Nova.escHtml(r.content||r.value||'')}${r.ttl||3600}
Del `).join('');
Nova.modal(`DNS: ${domain}`, `
+ Add Record
Name Type Content TTL ${rows||'No records yet. '}
`);
};
window.adminAddRecord = (zoneId, domain) => {
Nova.modal('Add Record', `
Name
Type A AAAA CNAME MX TXT NS CAA
Content
TTL
`,
`Add Record `);
};
window.adminDelRecord = async (id, zoneId, domain) => {
Nova.confirm('Delete this record?', async () => {
const r = await Nova.api('dns','delete-record',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); adminEditZone(zoneId,domain); }
else Nova.toast(r?.message,'error');
}, true);
};
window.adminDelZone = (id, domain) => {
Nova.confirm(`Delete DNS zone for ${domain}?`, async () => {
const r = await Nova.api('dns','delete-zone',{method:'POST',body:{zone_id:id}});
if (r?.success) { Nova.toast('Zone deleted','success'); adminPage('dns-zones'); }
else Nova.toast(r?.message,'error');
}, true);
};
// ── Nameservers ────────────────────────────────────────────────────────────
async function nameservers() {
const r = await Nova.api('server_setup','get');
const d = r?.data || {};
return `
`;
}
window.adminSaveNS = async () => {
const r = await Nova.api('server_setup','nameservers',{method:'POST',body:{ns1:document.getElementById('ns1')?.value,ns2:document.getElementById('ns2')?.value}});
if (r?.success) Nova.toast('Nameservers saved','success');
else Nova.toast(r?.message,'error');
};
window.adminSetHostname = async () => {
const r = await Nova.api('server_setup','set-hostname',{method:'POST',body:{hostname:document.getElementById('srvhost')?.value}});
if (r?.success) Nova.toast(`Hostname set to ${r.data?.hostname}`,'success');
else Nova.toast(r?.message,'error');
};
// ── Web Server ────────────────────────────────────────────────────────────
async function webServer() {
const r = await Nova.api('system', 'stats');
const svcs = r?.data?.services || {};
const webSvc = Object.keys(svcs).find(k => k.includes('apache') || k.includes('nginx')) || 'apache2';
return `
${Object.entries(svcs).map(([s,st]) => `
${s} ${Nova.badge(st,st==='active'?'green':'red')}
Restart
Start
Stop
`).join('')}
`;
}
// ── SSL Manager ────────────────────────────────────────────────────────────
async function sslManager() {
const res = await Nova.api('ssl', 'list', {params:{account_id:0}});
const certs = res?.data || [];
return `
${certs.length ? `
Domain Account Type Expires Days Actions
${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 `
${Nova.escHtml(c.domain)}
${Nova.escHtml(c.username||'—')}
${Nova.badge(c.type||'lets-encrypt','default')}
${c.expires_at||'—'}
${badge}
Renew
Del
`;
}).join('')}
`
: '
No SSL certificates yet.
'}
Let's Encrypt — Free automatic SSL via Certbot. Requires a publicly reachable domain (port 80 open). Use "Issue LE for All Domains" to auto-issue for every account.
Custom SSL — Upload a certificate from any CA (Comodo, DigiCert, GlobalSign, etc). Paste the certificate, private key, and CA chain. Use "Generate CSR" to create a signing request to send to your CA.
`;
}
window.adminIssueBulkSSL = async () => {
Nova.toast('Queuing SSL for all domains without certificates…','info',6000);
const accts = await Nova.api('accounts','list',{params:{limit:1000}});
let count = 0;
for (const a of (accts?.data || [])) {
await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain}});
count++;
}
Nova.toast(`SSL issued for ${count} domains`,'success');
adminPage('ssl-manager');
};
window.adminRenewCert = async (id) => {
Nova.toast('Renewing…','info');
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.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.
Organization
`,
`Generate CSR `);
};
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.
Certificate Signing Request (CSR)
Private Key (keep secret)
Copy CSR
Copy Key `);
};
window.adminInstallCustomSSL = () => {
Nova.modal('Upload Custom SSL Certificate', `
Paste the certificate and key from your CA. Chain/CA bundle is optional but recommended.
Domain
Certificate (PEM)
Private Key (PEM)
CA Chain / Bundle (optional)
`,
`Install Certificate `);
};
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 totalBanned = jails.reduce((s,j) => s + (j.currently_banned||0), 0);
return `
Incoming
${['deny','allow','reject'].map(p=>`${p.charAt(0).toUpperCase()+p.slice(1)} `).join('')}
Outgoing
${['allow','deny','reject'].map(p=>`${p.charAt(0).toUpperCase()+p.slice(1)} `).join('')}
Save Policies
Active Jails
${jails.length}
Currently Banned
${totalBanned}
Reload Config
Restart
${rules.length ? `
# To / Port Action From
${rules.map(r => `
${r.num}
${Nova.escHtml(r.to)}
${fwActionBadge(r.action)}
${Nova.escHtml(r.from)}
Delete
`).join('')}
` : `
`}
Action
Allow
Deny
Reject
Limit
Direction
In
Out
Port / Service
Protocol
TCP
UDP
Any
From IP (optional)
Comment
Add Rule
Allow
${trusted.length ? `
${trusted.map(ip => `${Nova.escHtml(ip)} × `).join('')}
` : `
No trusted IPs.
`}
Block
${blocked.length ? `
${blocked.map(ip => `${Nova.escHtml(ip)} × `).join('')}
` : `
No blocked IPs.
`}
${jails.length ? `
Jail Currently Banned Total Banned Failed Actions
${jails.map(j => `
${Nova.escHtml(j.jail)}
${j.currently_banned > 0 ? `${j.currently_banned} ` : '0'}
${j.total_banned}
${j.currently_failed}
${j.currently_banned > 0 ? `View Banned ` : ''}
Ban IP
`).join('')}
` : `
Fail2Ban not running or no jails configured.
`}
Add to Whitelist
${(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.
Log Level
${['off','on','low','medium','high','full'].map(l=>`${l.charAt(0).toUpperCase()+l.slice(1)} `).join('')}
Apply
Logs at /var/log/ufw.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',`
Action
Allow Deny
Reject Limit (rate-limit)
Direction
Incoming Outgoing
Port / Service
Protocol
TCP UDP Any
From IP / CIDR (leave blank for any)
To IP / CIDR (leave blank for any)
Comment
`,`Cancel
Add 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 ? `
Banned IP
${ips.map(ip=>`
${Nova.escHtml(ip)}
Unban
`).join('')}
` : '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}`,`
IP Address to Ban
`,`
Cancel
Ban IP `);
};
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}});
Nova.toast(r?.message || 'Logging updated', r?.success ? 'success' : '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] = await Promise.all([
Nova.api('system','db-engines'),
Nova.api('databases','list',{params:{account_id:0}}),
]);
const eng = engRes?.data?.engines || {};
const actE = engRes?.data?.active_engine || 'mysql';
const dbs = dbRes?.data || [];
const engineCard = (id, label, icon) => {
const e = eng[id] || {};
const statusColor = e.active ? 'green' : (e.installed ? 'red' : 'default');
const statusText = !e.installed ? 'Not Installed' : (e.active ? 'Running' : 'Stopped');
return `
${!e.installed
? `Install `
: `
Start
Stop
Restart
Remove `
}
${e.installed && id !== 'postgresql' ? `
phpMyAdmin ↗ ` : ''}
${e.installed && id === 'postgresql' ? `
pgAdmin ↗ ` : ''}
`;
};
const dbTable = dbs.length ? `
Database User Type Account Actions
${dbs.map(d=>`
${Nova.escHtml(d.db_name)}
${Nova.escHtml(d.db_user||'—')}
${Nova.badge(d.db_type||'mysql','default')}
${Nova.escHtml(d.username||'—')}
Drop
`).join('')}
` : 'No databases yet.
';
return `
${engineCard('mysql', 'MySQL', '🐬')}
${engineCard('mariadb', 'MariaDB', '🦭')}
${engineCard('postgresql','PostgreSQL','🐘')}
MySQL
MariaDB
PostgreSQL
Set Active
Currently: ${Nova.badge(actE,'green')}
${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');
};
// ── Mail Server ────────────────────────────────────────────────────────────
async function mailServer() {
const r = await Nova.api('system','stats');
const svcs = r?.data?.services || {};
const mailStatus = svcs['postfix'] || 'unknown';
const doveStatus = svcs['dovecot'] || 'unknown';
return `
${[['postfix',mailStatus],['dovecot',doveStatus]].map(([s,st]) => `
${s} ${Nova.badge(st,st==='active'?'green':'red')}
Restart
Reload
`).join('')}
`;
}
window.adminViewMailQueue = async () => {
const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}});
Nova.modal('Mail Queue', `${r?.data?.output || 'Queue is empty'} `);
};
// ── FTP Server ────────────────────────────────────────────────────────────
async function ftpServer() {
const [sRes, optsRes] = await Promise.all([
Nova.api('system','stats'),
Nova.api('system','server-options'),
]);
const svcs = sRes?.data?.services || {};
const ftpConf = optsRes?.data?.ftp_server || 'proftpd';
const svcName = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'pure-ftpd' : 'proftpd');
const label = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'Pure-FTPd' : 'ProFTPD');
const status = svcs[svcName] || 'unknown';
return `
Active FTP server: ${label} — change in Server Options .
FTP connections use SFTP on port 22 or passive FTP on ports 20/21.
Per-account FTP management is available in each account's FTP page.
`;
}
// ── Backups — delegates to backupsFull() defined in additions ─────────────
async function backups() { return backupsFull(); }
// ── Stubs for new pages — implementations in additions block below ─────────
async function wordpress() { return wordpressPage(); }
async function cloudflare() { return cloudflarePage(); }
async function twofa() { return twofaPage(); }
async function nginxProxy() { return nginxProxyPage(); }
async function sessions() { return sessionsPage(); }
// ── Global action helpers ──────────────────────────────────────────────────
window.adminPage = (page) => Nova.loadPage(page, pages);
window.applyNovaCPXUpdate = async () => {
Nova.confirm('Apply NovaCPX update? PHP syntax is checked first, and a backup is taken automatically. The panel will self-restore if anything breaks.', async () => {
Nova.loading('Pulling NovaCPX update from GitHub…');
const res = await Nova.api('system', 'apply-update', { method: 'POST' });
Nova.loadingDone();
const d = res?.data;
if (!res?.success) {
Nova.modal('Update Failed', `${Nova.escHtml(res?.message || 'Unknown error')}
`);
return;
}
if (d?.updated) {
const steps = (d.steps || []).map(s => `${Nova.escHtml(s)}
`).join('');
Nova.modal('Update Complete',
`Updated: ${Nova.escHtml(d.from_commit)} → ${Nova.escHtml(d.to_commit)}
${steps ? `${steps}
` : ''}
Backup saved to: ${Nova.escHtml(d.backup_path || '')}
`,
`OK `
);
} 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)}
` : ''}`,
`OK `
);
}
});
};
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…
Close `
);
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 `
${installs.length ? `
Domain Path Account Version Status Actions
${installs.map(w => `
${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')}
Info
Update Core
Plugins
Themes
${!w.staging_of ? `Clone Staging ` : `staging `}
Delete
`).join('')}
` : `
No WordPress installs yet. Click "Install WordPress" to get started.
`}
`;
}
window.wpInstallModal = () => {
const accts = window._adminAcctsWP || [];
const opts = accts.map(a => `${a.username} — ${a.domain} `).join('');
Nova.modal('Install WordPress', `
Account ${opts}
Domain
Path (leave / for root)
Site Title
WP Admin Username
WP Admin Password
WP Admin Email
wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.
`,
`Cancel
Install `);
};
window.wpSubmitInstall = async () => {
const btn = document.getElementById('wp-install-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; }
Nova.toast('Installing WordPress — this may take 1-2 minutes…', 'info', 90000);
const res = await Nova.api('wordpress','install',{method:'POST',body:{
account_id: +document.getElementById('wp-acct')?.value,
domain: document.getElementById('wp-domain')?.value,
path: document.getElementById('wp-path')?.value || '/',
site_title: document.getElementById('wp-title')?.value,
admin_user: document.getElementById('wp-admin')?.value,
admin_pass: document.getElementById('wp-adminpass')?.value,
admin_email:document.getElementById('wp-email')?.value,
}});
document.querySelector('.modal-overlay')?.remove();
if (res?.success) { Nova.toast('WordPress installed!','success'); adminPage('wordpress'); }
else Nova.toast(res?.message || 'Install failed','error');
};
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 ? `Plugin Version Status ${plugins}
` : 'None
'}
Themes (${(d.themes||[]).length})
${themes ? `Theme Version Status ${themes}
` : '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)}
Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.
${accts.slice(0,8).map(a => `${Nova.escHtml(a.username)} `).join('')}
${accts.length>8?`+${accts.length-8} more `:''}
${backupList.length ? `
Account Type Size Status Storage Created Actions
${backupList.map(b => `
${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 `:''}
Restore
Del
`).join('')}
` : `
No backups yet.
`}
`;
}
window.bkCreateModal = () => {
const accts = window._adminAcctsBK || [];
const opts = accts.map(a => `${a.username} — ${a.domain} `).join('');
Nova.modal('Create Backup', `
Account ${opts}
Type
Full (files + database)
Files only
Database only
`,
`Cancel
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 => `${a.username} `).join('');
Nova.modal('Configure Backup Schedule', `
Account ${opts}
Frequency
Hourly
Daily
Weekly
Monthly
Type
Full
Files only
Database only
Keep (# backups)
`,
`Cancel
Save 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}`, `
Frequency
${['hourly','daily','weekly','monthly'].map(f=>`${f.charAt(0).toUpperCase()+f.slice(1)} `).join('')}
Type
${['full','files','database'].map(t=>`${t.charAt(0).toUpperCase()+t.slice(1)} `).join('')}
Keep (# backups)
`,
`Cancel
Save `);
};
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 `
Select an account to configure or view its Cloudflare API key.
Account
— Select Account —
${accts.map(a=>`${a.username} — ${a.domain} `).join('')}
Save credentials first, then click Refresh Zones.
`;
}
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 = `
Save Credentials
Test API Key
${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 = `
Zone Status Plan Actions
${zones.map(z=>`
${Nova.escHtml(z.name)} ${Nova.escHtml(z.id)}
${Nova.badge(z.status,z.status==='active'?'green':'yellow')}
${Nova.escHtml(z.plan?.name||'—')}
DNS Records
Push to CF
Pull from CF
Purge Cache
`).join('')}
`;
};
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.
' : `
`);
};
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 `
Username Email Role 2FA Status Actions
${users.map(u=>`
${Nova.escHtml(u.username)}
${Nova.escHtml(u.email||'—')}
${Nova.badge(u.role||'user','default')}
—
Check
Force Disable
`).join('')}
`;
}
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
${inst ? (run ? 'Running' : 'Stopped') : 'Not Configured'}
${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 ? `
🖥
Local Mode
nginx on this server . Apache moves to an internal port. All websites keep working — nginx proxies everything through. One-click setup.
Enable Local Mode
🌐
Remote Proxy VM
Dedicated LXC or VM runs nginx. Panel pushes configs via SSH. Best for production — keeps proxy and hosting isolated.
Configure Remote VM
Setup Guide & Requirements
` : `
${hosts.length === 0 ? `
No proxy hosts yet. Click Sync Accounts to auto-add all hosted domains, or + Add Host to add manually.
` : `
Domain
Upstream
SSL
Status
Actions
${hosts.map(h => `
${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')}
Edit
${h.enabled ? 'Disable' : 'Enable'}
Delete
`).join('')}
`}
`}`;
}
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 = () => {
Nova.modal('Add Proxy Host', `
Domain
Upstream URL
e.g. http://127.0.0.1:80 or http://10.0.0.2:8080
Enable SSL
Notes (optional)
`, async () => {
const domain = document.getElementById('ph-domain')?.value?.trim();
const upstream = document.getElementById('ph-upstream')?.value?.trim();
if (!domain || !upstream) { Nova.toast('Domain and upstream required', 'error'); return; }
const r = await Nova.api('proxy', 'hosts', {
method: 'POST',
body: { domain, upstream, ssl_enabled: document.getElementById('ph-ssl')?.checked ? 1 : 0 }
});
Nova.toast(r?.success ? 'Host added' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
});
};
window.proxyEditHost = async (id) => {
const hostsR = await Nova.api('proxy', 'hosts');
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
const h = hosts.find(x => x.id == id);
if (!h) return;
Nova.modal('Edit Proxy Host', `
Domain
Upstream URL
Enable SSL
Custom Nginx Config (overrides auto-generated)
Leave blank to use auto-generated config
`, async () => {
const r = await Nova.api('proxy', 'host', {
method: 'PUT',
body: { id,
domain: document.getElementById('phe-domain')?.value?.trim(),
upstream: document.getElementById('phe-upstream')?.value?.trim(),
ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0,
custom_config: document.getElementById('phe-custom')?.value?.trim() || null,
}
});
Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
});
};
window.proxyToggle = async (id, enable) => {
const r = await Nova.api('proxy', 'toggle', { method: 'POST', body: { id, enabled: enable } });
Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
};
window.proxyDeleteHost = (id, domain) => {
Nova.confirm(`Delete proxy host for ${domain}?`, async () => {
const r = await Nova.api('proxy', 'host', { method: 'DELETE', body: { id } });
Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error');
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
}, true);
};
window.proxySetupInstructions = async () => {
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.
In Proxmox: Create CT → Debian 12 → 512MB RAM, 8GB disk, same bridge as NovaCPX VM
Boot the LXC, set root password
Go to Settings → set Mode=Remote, enter the LXC IP, root password, and this VM's IP as Backend IP
Click Run Setup on Remote VM — watch live progress
Point your router/firewall port 80/443 to the LXC IP
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.
Create a Debian/Ubuntu VM (1 vCPU, 512MB RAM)
Enable SSH root login: PermitRootLogin yes in /etc/ssh/sshd_config
Install sshpass on the NovaCPX server: apt-get install -y sshpass
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.
Provision a small Debian droplet/instance in the same region or with low latency to NovaCPX
Open port 22 (SSH) from NovaCPX's IP only; open 80/443 from anywhere
Set Backend IP to NovaCPX's IP reachable from the cloud proxy (use VPN/private IP if possible)
In Settings: set Remote Host to the cloud server's public IP or hostname
Click Run Setup, then Sync Accounts
Option D — Local nginx on this VM
Not recommended — requires moving Apache off port 80/443 first.
Edit /etc/apache2/ports.conf → change Listen 80 to Listen 8090, restart Apache
Set Settings → Mode = Local, Backend IP = 127.0.0.1
Click Install Nginx Locally
Set upstream http://127.0.0.1:8090 on all proxy hosts
Click Sync Accounts
Settings Reference (Admin → Nginx Proxy → Settings)
Field Description
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
Each domain gets an nginx vhost config on the proxy VM, proxying to Apache on the backend IP
Configs are pushed automatically when accounts are created/terminated or manually via Sync Accounts
The panel starts/stops/reloads nginx on the proxy VM over SSH
Every 5 minutes the health check verifies nginx is running and restarts it if not
Use Uninstall to remove proxy configs or wipe nginx from the remote VM entirely
`, null, { cancelLabel: 'Close', showConfirm: false });
};
window.proxySwitchLocal = () => {
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
Apache backend port (any unused port; 8090 is the default)
`, () => {
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');
const es = new EventSource('/api/proxy/switch-local');
let done = false;
// POST with port — can't use native EventSource for POST, so use fetch+ReadableStream
es.close();
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; });
}, { confirmLabel: 'Switch Now' });
};
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
`, null, { cancelLabel: 'Close', showConfirm: false });
const log = document.getElementById('proxy-setup-log');
const es = new EventSource('/api/proxy/setup-remote');
let done = false;
es.onmessage = (e) => {
try {
const d = JSON.parse(e.data);
if (d.line) { log.textContent += d.line; log.scrollTop = log.scrollHeight; }
if (d.done) { done = true; es.close(); log.textContent += '\n— Done. Refreshing status…\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1200); }
} catch {}
};
es.onerror = () => {
if (!done) {
es.close();
log.textContent += '\n— Connection lost. Check remote host settings and try again.\n';
}
};
// Close SSE when modal is dismissed
ov.querySelector('.modal-close')?.addEventListener('click', () => es.close());
};
window.proxyUninstall = () => {
Nova.modal('Uninstall Nginx Proxy', `
Choose what to remove from the remote proxy VM :
Remove proxy host configs only (keep nginx running)
Remove everything (uninstall nginx, delete all configs, disable proxy mode)
`, async () => {
const full = document.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) Nova.loadPage('nginx-proxy', window._novaPages);
}, { confirmLabel: 'Uninstall', danger: true });
};
window.proxySettings = async () => {
const r = await Nova.api('proxy', 'settings');
const cfg = r?.data || {};
const ov = Nova.modal('Nginx Proxy Settings', `
Proxy Mode
Disabled
Remote VM (SSH)
Local (nginx on this VM)
`,
`Cancel
Save 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}
${rows.length === 0
? '
No active sessions
'
: `
User Role IP Browser Created Expires Actions
${rows.map(s=>`
${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)}
Revoke
All for User
`).join('')}
`}
`;
}
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.
Install Docker CE
`;
}
window._dockerTab = window._dockerTab || 'containers';
const tab = (id, label) => `${label} `;
window.dockerTab = async (id) => {
window._dockerTab = id;
document.querySelectorAll('[onclick^="dockerTab"]').forEach(b => {
b.className = 'btn btn-sm ' + (b.getAttribute('onclick').includes(`'${id}'`) ? 'btn-primary' : 'btn-ghost');
});
await dockerLoadTab(id);
};
window.dockerPrune = () => Nova.confirm('Remove all stopped containers, unused images, and build cache?', async () => {
const r = await Nova.api('docker', 'prune', { method: 'POST', body: { volumes: false } });
Nova.toast(r?.success ? 'Pruned' : 'Failed', r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab(window._dockerTab);
}, true);
setTimeout(() => dockerLoadTab(window._dockerTab), 100);
return `
Engine
${Nova.escHtml(status.version || '—')}
${status.running ? 'Running' : 'Stopped'}
${(status.disk||[]).map(d=>`
${Nova.escHtml(d.Type||d.type||'?')}
${Nova.escHtml(d.TotalCount||d.Size||'—')}
${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}
`).join('')}
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')}
System Prune
`;
}
async function dockerLoadTab(tab) {
const tc = document.getElementById('docker-tab-content');
if (!tc) return;
tc.innerHTML = 'Loading…
';
if (tab === 'containers') {
const r = await Nova.api('docker', 'containers');
const rows = r?.data?.containers || [];
tc.innerHTML = `
${rows.length} containers
+ Run Container
${rows.length === 0 ? '' : `
Name Image Status Account Created Actions
${rows.map(c => `
${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'
? `Stop
Restart `
: `Start `}
Logs
Remove
`).join('')}
`}`;
} else if (tab === 'images') {
const r = await Nova.api('docker', 'images');
const imgs = r?.data?.images || [];
tc.innerHTML = `
${imgs.length} images
Pull Image
${imgs.length === 0 ? 'No images
' : `
Repository Tag ID Size Actions
${imgs.map(i => `
${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||'—')}
Remove
`).join('')}
`}`;
} 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
' : `
Name Driver Scope
${vols.map(v=>`${Nova.escHtml(v.Name||v.name||'')} ${Nova.escHtml(v.Driver||v.driver||'')} ${Nova.escHtml(v.Scope||v.scope||'')} `).join('')}
`}`;
} 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
' : `
Name Driver Scope ID
${nets.map(n=>`${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))} `).join('')}
`}`;
} else if (tab === 'stacks') {
const r = await Nova.api('docker', 'stacks');
const stacks = r?.data?.stacks || [];
tc.innerHTML = `
${stacks.length} stacks
+ Create Stack
${stacks.length === 0 ? 'No compose stacks
' : `
Name Status Account Created Actions
${stacks.map(s=>`
${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()}
Up
Down
Logs
Remove
`).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.
Username Max Containers Max Memory Max CPUs Actions
${users.map(u=>`
${Nova.escHtml(u.username)}
2
512 MB
1.0
Edit
`).join('')}
`;
}
}
window.dockerContainerAct = async (cid, action) => {
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('containers');
};
window.dockerRemove = (cid) => Nova.confirm('Remove this container?', async () => {
const r = await Nova.api('docker', 'container-remove', { method: 'DELETE', body: { container_id: cid, force: true } });
Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('containers');
}, true);
window.dockerLogs = async (cid, name) => {
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 200 } });
const logs = r?.data?.logs || r?.message || 'No logs';
Nova.modal(`Logs: ${name}`, `${Nova.escHtml(logs)} `);
};
window.dockerImgRemove = (id) => Nova.confirm('Remove this image?', async () => {
const r = await Nova.api('docker', 'image-remove', { method: 'DELETE', body: { image_id: id } });
Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('images');
}, true);
window.dockerPullModal = () => {
const ov = Nova.modal('Pull Image',
`Image Name
`,
`Cancel
Pull `
);
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',
`Image
Name
Account ID
Ports (host:container, one per line)
Memory (MB)
CPUs
`,
`Cancel
Run `
);
window.dockerRunSubmit = async () => {
const image = document.getElementById('dr-image').value.trim();
const name = document.getElementById('dr-name').value.trim();
const acct = parseInt(document.getElementById('dr-acct').value) || 0;
const ports = document.getElementById('dr-ports').value.trim().split('\n').map(p=>p.trim()).filter(Boolean);
const mem = parseInt(document.getElementById('dr-mem').value) || 256;
const cpus = parseFloat(document.getElementById('dr-cpus').value) || 0.5;
if (!image || !name || !acct) { Nova.toast('Image, name and account required','error'); return; }
ov.remove();
const r = await Nova.api('docker', 'container-run', { method: 'POST', body: { image, name, account_id: acct, ports, memory_mb: mem, cpus } });
Nova.toast(r?.success ? 'Container started' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('containers');
};
};
window.dockerStackAct = async (id, action) => {
Nova.toast(`Running docker compose ${action}…`, 'info', 5000);
const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: id, action } });
if (action === 'logs') {
Nova.modal('Stack Logs', `${Nova.escHtml(r?.data?.output||'')} `);
} else {
Nova.toast(r?.success ? `Stack ${action} complete` : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) dockerLoadTab('stacks');
}
};
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) dockerLoadTab('stacks');
}, true);
window.dockerStackCreateModal = () => {
const ov = Nova.modal('Create Compose Stack',
`Stack Name
Account ID (leave blank for admin)
docker-compose.yml content
`,
`Cancel
Create `
);
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}`,
`Max Containers
Max Memory (MB)
Max CPUs
`,
`Cancel
Save `
);
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 `
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')}
Customer Hosting Web Server
${['apache','nginx'].map(s=>`${s.charAt(0).toUpperCase()+s.slice(1)} `).join('')}
Save & Switch
Mail stack for all hosted domains.
Mail Stack
Postfix + Dovecot
Postfix + Dovecot + Rspamd (spam filter)
Save & Switch
FTP server for hosting account file transfers.
FTP Server
ProFTPD (default)
vsftpd
Pure-FTPd
Save & Switch
DNS server for authoritative name service.
DNS Server
BIND9 (default)
PowerDNS
NSD
No local DNS (external API)
Save & Switch
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.
WHMCS Enabled
Disabled
Enabled
Provisioning API URL (set this in WHMCS server module)
Save WHMCS Settings
`;
}
window.soSave = async (key, inputId, label) => {
const val = document.getElementById(inputId)?.value;
if (!val) return;
Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, async () => {
Nova.loading(`Switching ${label} to ${val}…`);
const r = await Nova.api('system', 'save-option', { method:'POST', body:{ key, value: val } });
Nova.loadingDone();
Nova.toast(r?.success ? `${label} switched to ${val}` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('server-options');
}, true);
};
window.soSaveWhmcs = async () => {
const key = document.getElementById('so-whmcs-key')?.value?.trim();
const enabled = document.getElementById('so-whmcs-enabled')?.value;
const r1 = await Nova.api('system', 'save-option', { method:'POST', body:{ key:'whmcs_api_key', value:key } });
const r2 = await Nova.api('system', 'save-option', { method:'POST', body:{ key:'whmcs_enabled', value:enabled } });
Nova.toast((r1?.success && r2?.success) ? 'WHMCS settings saved' : 'Save failed', (r1?.success && r2?.success)?'success':'error');
};
window.soSaveNS = async () => {
const ns1 = document.getElementById('so-ns1')?.value?.trim();
const ns2 = document.getElementById('so-ns2')?.value?.trim();
await Nova.api('system', 'save-option', { method:'POST', body:{ key:'ns1_hostname', value:ns1 } });
await Nova.api('system', 'save-option', { method:'POST', body:{ key:'ns2_hostname', value:ns2 } });
Nova.toast('Nameservers saved', 'success');
};
window.soCheckNS = async () => {
const tc = document.getElementById('so-ns-results');
if (!tc) return;
tc.innerHTML = 'Checking NS records…
';
const r = await Nova.api('dns', 'ns-health');
const results = r?.data?.results || [];
if (!results.length) { tc.innerHTML = 'No zones to check, or DNS manager not configured.
'; return; }
tc.innerHTML = `Domain NS1 NS2 Status
${results.map(z=>`
${Nova.escHtml(z.domain)}
${Nova.escHtml(z.ns1||'—')}
${Nova.escHtml(z.ns2||'—')}
${z.ok ? Nova.badge('OK','green') : Nova.badge('Mismatch','red')}
`).join('')}
`;
};