-
+
+
- ${hist.length === 0
- ? '
No history yet — collected every 5 minutes via cron.
'
- : '
'}
+ ${hist.length === 0 ? '
No history yet — collected every 5 minutes.
' : '
'}
`;
}
+ setTimeout(() => { const c = document.getElementById('dash-hist-chart'); if (!c || !hist.length) return; if (!window.Chart) { const s2 = document.createElement('script'); s2.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'; s2.onload = () => initStatsChart(c, hist); document.head.appendChild(s2); } else { initStatsChart(c, hist); } }, 150);
+
+ // ── Server Status ──────────────────────────────────────────────────────────
function initStatsChart(canvas, hist) {
const labels = hist.map(r => {
@@ -221,6 +204,7 @@
});
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: {
@@ -229,17 +213,1224 @@
{ label: 'CPU %', data: hist.map(r=>parseFloat(r.cpu_usage||0)), borderColor:'#6366f1', backgroundColor:'rgba(99,102,241,.1)', tension:.3, pointRadius:0, fill:true },
{ label: 'RAM %', data: hist.map(r=>parseFloat(r.ram_usage||0)), borderColor:'#0ea5e9', backgroundColor:'rgba(14,165,233,.1)', tension:.3, pointRadius:0, fill:true },
{ label: 'Disk %', data: hist.map(r=>parseFloat(r.disk_usage||0)), borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,.08)', tension:.3, pointRadius:0, fill:true },
- ]
+ ],
},
options: {
- responsive:true, maintainAspectRatio:true, interaction:{mode:'index',intersect:false},
- plugins:{ legend:{ position:'top', labels:{ color:'#8b90a8', boxWidth:10, font:{size:10} } } },
- scales:{
- x:{ ticks:{ color:'#8b90a8', maxRotation:0, font:{size:10} }, grid:{ color:'rgba(255,255,255,.05)' } },
- y:{ min:0, max:100, ticks:{ color:'#8b90a8', font:{size:10}, callback:v=>v+'%' }, grid:{ color:'rgba(255,255,255,.05)' } }
+ responsive: true,
+ animation: false,
+ interaction: { mode:'index', intersect:false },
+ scales: {
+ x: { grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', maxRotation:0 } },
+ y: { min:0, max:100, grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', callback: v=>v+'%' } },
+ },
+ plugins: {
+ legend: { labels:{ color:'#e2e4f0', font:{ size:12 } } },
+ tooltip: { callbacks:{ label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%` } },
+ },
+ },
+ });
+ }
+
+ // ── Updates ────────────────────────────────────────────────────────────────
+ async function updates(force = false) {
+ const qp = force ? { force: 1 } : {};
+ const [ver, ncpxCheck, osCheck] = await Promise.all([
+ Nova.api('system', 'version'),
+ Nova.api('system', 'check-novacpx-update', { params: qp }),
+ Nova.api('system', 'check-os-update', { params: qp }),
+ ]);
+ const v = ver?.data || {};
+ const ncpx = ncpxCheck?.data || {};
+ const os = osCheck?.data || {};
+ const ncpxCount = ncpx.updates_available || 0;
+ const osCount = os.upgradable || 0;
+
+ const html = `
+
+
+
+
+
+
+
+
Installed
${v.installed_version || '—'}
+
Latest (${ncpx.channel || 'stable'})
${ncpx.remote_version || (ncpxCount > 0 ? 'available' : v.installed_version || '—')}
+
Channel
${Nova.badge(ncpx.channel || 'stable', ncpx.channel === 'beta' ? 'yellow' : 'green')}
+
PHP
${v.php_version || '—'}
+
+
+ ${ncpxCount > 0 ? `
+
+
+
+ ${ncpx.commits?.map(c => `
${Nova.escHtml(c)}
`).join('') || 'None'}
+
+
+
PHP syntax is validated before deploy. If the panel goes down after update, it will automatically restore from backup.
+
+ ` : `
NovaCPX is up to date.
`}
+
+
+
+
+
+
+
+
Loading service inventory…
+
+
+
+
+
+
+
+ ${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.
+
+ ` : `
All OS packages are current.
`}
+
+
`;
+
+ setTimeout(loadServiceVersions, 80);
+ return html;
+ }
+
+ window.forceRefreshUpdates = () => {
+ const content = document.getElementById('page-content');
+ if (!content) return;
+ content.innerHTML = '
Checking for updates…
';
+ updates(true).then(html => { if (html) content.innerHTML = html; });
+ };
+
+ window.loadServiceVersions = async () => {
+ const body = document.getElementById('svc-versions-body');
+ if (!body) return;
+ body.innerHTML = '
Scanning installed services…
';
+ const r = await Nova.api('system', 'service-versions');
+ const svcs = r?.data?.services || [];
+ if (!svcs.length) { body.innerHTML = '
No tracked services found.
'; return; }
+ const statusDot = s => s === 'active'
+ ? '
● running'
+ : s === null ? '
—'
+ : '
● stopped';
+ body.innerHTML = `
+ | Service | Description | Installed | Latest | Status | State |
+ ${svcs.map(s => `
+ ${Nova.escHtml(s.label)}
${Nova.escHtml(s.pkg)} |
+ ${Nova.escHtml(s.desc)} |
+ ${Nova.escHtml(s.installed)} |
+ ${Nova.escHtml(s.latest)} |
+ ${s.up_to_date === true ? Nova.badge('current','green') : s.up_to_date === false ? Nova.badge('update available','yellow') : '—'} |
+ ${statusDot(s.status)} |
+
`).join('')}
+
`;
+ };
+
+ // ── 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 => `
+
+ `).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 ? `
+
+
+ ${!v.is_default ? `` : ''}
+ ` : `
+
+ `}
+
+
`).join('')}
+
+
+
+
+
`;
+ }
+
+ window.phpInstallVersion = (ver) => {
+ Nova.confirm(`Install PHP ${ver}? This will run apt-get and may take a minute.`, async () => {
+ Nova.loading(`Installing PHP ${ver}…`);
+ const r = await Nova.api('php', 'install-version', { method: 'POST', body: { version: ver } });
+ Nova.loadingDone();
+ if (r?.success) { Nova.toast(`PHP ${ver} installed`, 'success'); adminPage('php-manager'); }
+ else Nova.toast(r?.message || 'Install failed', 'error');
+ });
+ };
+
+ window.phpRemoveVersion = (ver) => {
+ Nova.confirm(`Remove PHP ${ver}? All FPM pools for this version will stop.`, async () => {
+ Nova.loading(`Removing PHP ${ver}…`);
+ const r = await Nova.api('php', 'remove-version', { method: 'POST', body: { version: ver } });
+ Nova.loadingDone();
+ if (r?.success) { Nova.toast(`PHP ${ver} removed`, 'success'); adminPage('php-manager'); }
+ else Nova.toast(r?.message || 'Remove failed', 'error');
+ }, true);
+ };
+
+ window.phpFpmAction = async (ver, cmd) => {
+ Nova.loading(`${cmd} php${ver}-fpm…`);
+ const r = await Nova.api('php', 'fpm-action', { method: 'POST', body: { version: ver, command: cmd } });
+ Nova.loadingDone();
+ if (r?.success) { Nova.toast(r.message, 'success'); refreshSvcStatus(`php${ver}-fpm`); }
+ else Nova.toast(r?.message || 'Action failed', 'error');
+ };
+
+ window.phpExtModal = async (ver) => {
+ const panel = document.getElementById('php-ext-panel');
+ if (!panel) return;
+ panel.style.display = '';
+ panel.innerHTML = `
Loading extensions for PHP ${ver}…
`;
+ panel.scrollIntoView({ behavior: 'smooth' });
+
+ const r = await Nova.api('php', 'version-extensions', { params: { version: ver } });
+ if (!r?.success) { panel.innerHTML = `
${r?.message || 'Failed to load'}
`; return; }
+
+ const installed = r.data.installed || [];
+ const available = r.data.available || [];
+ const notInstalled = available.filter(pkg => {
+ const ext = pkg.replace(/^php[\d.]+-/, '');
+ return !installed.some(i => i.toLowerCase() === ext.toLowerCase() || i.toLowerCase().replace('_','-') === ext.toLowerCase());
+ });
+
+ panel.innerHTML = `
+
+
+
+
+
Add extension
+
+
+ or
+
+
+
+
+
+
+ | Extension | Action |
+
+ ${installed.map(e => `
+
+ ${e} |
+
+
+ |
+
`).join('')}
+
+
+
+
+
`;
+ };
+
+ window.phpExtFilter = (q) => {
+ document.querySelectorAll('.php-ext-row').forEach(row => {
+ row.style.display = row.dataset.ext.includes(q.toLowerCase()) ? '' : 'none';
+ });
+ };
+
+ const _phpExtStream = (ver, ext, action) => {
+ const termId = 'phpext-term-' + Date.now();
+ const verb = action === 'install-extension' ? 'Installing' : 'Removing';
+ Nova.modal(`${verb} ${ext} (PHP ${ver})`, `
+
+ Starting…\n
+
`,
+ `
`);
+ const term = document.getElementById(termId);
+ const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
+ fetch(`/api/php/${action}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ version: ver, extension: ext }),
+ credentials: 'same-origin',
+ }).then(resp => {
+ if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
+ const reader = resp.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '';
+ const read = () => reader.read().then(({ done, value }) => {
+ if (done) { append('\n[done]'); return; }
+ buf += dec.decode(value, { stream: true });
+ const parts = buf.split('\n\n');
+ buf = parts.pop();
+ for (const part of parts) {
+ const m = part.match(/^data: (.+)$/m);
+ if (!m) continue;
+ try {
+ const obj = JSON.parse(m[1]);
+ if (obj.line) { append(obj.line); }
+ else if (obj.done) {
+ const btn = document.getElementById('phpext-close');
+ if (btn) {
+ btn.textContent = obj.success ? 'Done ✓' : 'Close';
+ btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost';
+ btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); phpExtModal(ver); };
+ }
+ }
+ } catch(e) {}
}
+ read();
+ }).catch(err => append(`\n[error: ${err.message}]`));
+ read();
+ }).catch(err => append(`\nFetch error: ${err.message}`));
+ };
+
+ window.phpExtInstall = (ver) => {
+ const sel = document.getElementById('php-ext-add-sel')?.value;
+ const custom = document.getElementById('php-ext-add-custom')?.value?.trim();
+ const ext = custom || sel;
+ if (!ext) { Nova.toast('Choose or type an extension name', 'error'); return; }
+ _phpExtStream(ver, ext, 'install-extension');
+ };
+
+ window.phpExtRemove = (ver, ext) => {
+ Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, () => {
+ _phpExtStream(ver, ext, 'remove-extension');
+ }, true);
+ };
+
+ // ── Notifications (#25) ───────────────────────────────────────────────────
+ async function notifications() {
+ const res = await Nova.api('system', 'notify-settings');
+ const s = res?.data || {};
+ setTimeout(etLoadList, 80);
+ return `
+
+
+
+
+
`;
+ }
+
+ document.addEventListener('submit', async e => {
+ if (!e.target.matches('#notify-form')) return;
+ e.preventDefault();
+ const fd = new FormData(e.target);
+ const body = Object.fromEntries(fd.entries());
+ if (!body.cybermail_api_key) delete body.cybermail_api_key;
+ const res = await Nova.api('system', 'save-notify-settings', { method: 'POST', body });
+ if (res?.success) Nova.toast('Notification settings saved', 'success');
+ else Nova.toast(res?.message || 'Save failed', 'error');
+ });
+
+ window.notifyTest = async () => {
+ const email = prompt('Send test email to:');
+ if (!email) return;
+ const res = await Nova.api('system', 'test-notify', { method: 'POST', body: { to: email } });
+ if (res?.success) Nova.toast(res.message, 'success');
+ else Nova.toast(res?.message || 'Send failed', 'error');
+ };
+
+ // ── Email Template Management ──────────────────────────────────────────────
+ window.etLoadList = async () => {
+ const body = document.getElementById('et-list-body');
+ if (!body) return;
+ body.innerHTML = '
Loading templates…
';
+ const r = await Nova.api('system', 'email-templates');
+ const tmpls = r?.data?.templates || [];
+ if (!tmpls.length) {
+ body.innerHTML = '
No templates found. Create one.
';
+ return;
+ }
+ body.innerHTML = `
+ | Trigger | Label | Subject | Status | Actions |
+ ${tmpls.map(t => `
+ ${Nova.escHtml(t.trigger_key)} |
+ ${Nova.escHtml(t.label)} |
+ ${Nova.escHtml(t.subject)} |
+ ${t.enabled ? Nova.badge('enabled','green') : Nova.badge('disabled','red')} |
+
+
+
+
+ |
+
`).join('')}
+
`;
+ };
+
+ window.etEdit = async (id) => {
+ const r = await Nova.api('system', 'email-template-get', { method: 'POST', body: { id } });
+ if (!r?.success) { Nova.toast(r?.message || 'Load failed', 'error'); return; }
+ const t = r.data;
+ Nova.modal(id ? `Edit Template: ${t.label}` : 'New Template', `
+
+
+
+
+
+
+
+
+
+
+
+
`,
+ `
+
`
+ );
+ };
+
+ window.etNew = () => {
+ Nova.modal('New Email Template', `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`,
+ `
+
`
+ );
+ };
+
+ window.etSave = async (id) => {
+ const subject = document.getElementById('et-subject')?.value?.trim();
+ const body_html = document.getElementById('et-html')?.value?.trim();
+ const body_text = document.getElementById('et-text')?.value?.trim();
+ const enabled = document.getElementById('et-enabled')?.value ?? '1';
+ if (!subject || !body_html) { Nova.toast('Subject and HTML body required', 'error'); return; }
+ const extra = id ? {} : {
+ trigger_key: document.getElementById('et-trigger')?.value?.trim(),
+ label: document.getElementById('et-label')?.value?.trim(),
+ };
+ const r = await Nova.api('system', 'email-template-save', { method: 'POST', body: { id, subject, body_html, body_text, enabled, ...extra } });
+ if (r?.success) {
+ Nova.toast('Template saved', 'success');
+ document.querySelector('.modal-overlay')?.remove();
+ etLoadList();
+ } else { Nova.toast(r?.message || 'Save failed', 'error'); }
+ };
+
+ window.etDelete = (id, label) => {
+ Nova.confirm(`Delete template "${label}"? This cannot be undone.`, async () => {
+ const r = await Nova.api('system', 'email-template-delete', { method: 'POST', body: { id } });
+ if (r?.success) { Nova.toast('Template deleted', 'success'); etLoadList(); }
+ else Nova.toast(r?.message || 'Delete failed', 'error');
+ }, true);
+ };
+
+ window.etSendTest = async (id) => {
+ const email = prompt('Send test email to:');
+ if (!email) return;
+ const r = await Nova.api('system', 'email-template-test', { method: 'POST', body: { id, to: email } });
+ if (r?.success) Nova.toast(r.message, 'success');
+ else Nova.toast(r?.message || 'Send failed', 'error');
+ };
+
+ // ── Settings ───────────────────────────────────────────────────────────────
+ async function settings() {
+ const r = await Nova.api('system', 'server-options');
+ const o = r?.data || {};
+ const cur = {
+ panel_name: o.panel_name || 'NovaCPX',
+ default_php: o.default_php || '8.3',
+ ns1: o.default_nameserver1 || '',
+ ns2: o.default_nameserver2 || '',
+ channel: o.update_channel || 'stable',
+ };
+ const phpOpts = ['7.4','8.1','8.2','8.3'].map(v =>
+ `
`).join('');
+ const chanOpts = [
+ ['stable', 'Stable — major releases (main branch)'],
+ ['beta', 'Beta — minor & patch releases (beta branch)'],
+ ].map(([v, l]) => `
`).join('');
+ return `
+
`;
+ }
+
+ window.adminSaveSettings = async () => {
+ const btn = document.querySelector('#settings-form button[type=submit]');
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
+ const saves = [
+ ['panel_name', document.getElementById('sf-panel-name')?.value?.trim()],
+ ['default_php', document.getElementById('sf-default-php')?.value],
+ ['default_nameserver1',document.getElementById('sf-ns1')?.value?.trim()],
+ ['default_nameserver2',document.getElementById('sf-ns2')?.value?.trim()],
+ ['update_channel', document.getElementById('sf-channel')?.value],
+ ].filter(([, v]) => v != null);
+ let ok = true;
+ for (const [key, value] of saves) {
+ const res = await Nova.api('system', 'save-option', { method: 'POST', body: { key, value } });
+ if (!res?.success) { ok = false; Nova.toast(`Failed to save ${key}`, 'error'); break; }
+ }
+ if (ok) Nova.toast('Settings saved', 'success');
+ if (btn) { btn.disabled = false; btn.textContent = 'Save Settings'; }
+ };
+
+ // ── Accounts ───────────────────────────────────────────────────────────────
+ async function accounts() {
+ const res = await Nova.api('accounts', 'list');
+ const accts = res?.data || [];
+ window._adminAccts = accts;
+ return `
+
+
+
+ ${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)} |
+
+
+
+ ${a.status==='active'
+ ? ``
+ : ``}
+
+
+ |
+
`).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}`, `
`,
+ `
`);
+ };
+ window.adminTerminate = (id, user) => {
+ Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, DBs, DNS, email. IRREVERSIBLE.`, async () => {
+ const res = await Nova.api('accounts','terminate',{method:'POST',body:{account_id:id}});
+ if (res?.success) { Nova.toast('Terminated','success'); adminPage('accounts'); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+ };
+
+ window.adminEditAccount = async (id) => {
+ Nova.loading('Loading account…');
+ const [acctRes, pkgRes, usersRes, dnsRes] = await Promise.all([
+ Nova.api('accounts', 'get', { params: { id } }),
+ Nova.api('packages', 'list'),
+ Nova.api('users', 'list', { params: { role: 'reseller' } }),
+ Nova.api('dns', 'zones'),
+ ]);
+ Nova.loadingDone();
+ if (!acctRes?.success) { Nova.toast(acctRes?.message || 'Failed to load account', 'error'); return; }
+ const a = acctRes.data;
+ const pkgs = pkgRes?.data || [];
+ const resellers = (usersRes?.data || []).filter(u => u.role === 'reseller');
+ const zone = (dnsRes?.data || []).find(z => z.account_id == id || z.domain === a.domain);
+
+ const pkgOpts = `
` +
+ pkgs.map(p => `
`).join('');
+ const phpOpts = ['8.3','8.2','8.1','7.4'].map(v =>
+ `
`).join('');
+ const ownerOpts = `
` +
+ resellers.map(r => `
`).join('');
+
+ Nova.modal(`Edit Account — ${Nova.escHtml(a.username)}`,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
DNS Zone — ${Nova.escHtml(a.domain)}
+
+ ${zone ? `
Zone ID: ${zone.id} · Serial: ${zone.serial}
` : '
No DNS zone found for this account
'}
+
`,
+ `
+
`
+ );
+ };
+
+ window.adminEditAccountSave = async (id) => {
+ const body = {
+ id,
+ email: document.getElementById('ae-email')?.value?.trim(),
+ reseller_id: document.getElementById('ae-owner')?.value || null,
+ package_id: document.getElementById('ae-pkg')?.value || null,
+ php_version: document.getElementById('ae-php')?.value,
+ ns1: document.getElementById('ae-ns1')?.value?.trim(),
+ ns2: document.getElementById('ae-ns2')?.value?.trim(),
+ };
+ Nova.loading('Saving account…');
+ const res = await Nova.api('accounts', 'update', { method: 'POST', body });
+ Nova.loadingDone();
+ if (res?.success) {
+ document.querySelector('.modal-overlay')?.remove();
+ Nova.toast('Account updated', 'success');
+ adminPage('accounts');
+ } else {
+ Nova.toast(res?.message || 'Update failed', 'error');
+ }
+ };
+
+ // ── Create Account ─────────────────────────────────────────────────────────
+ async function createAccount() {
+ const pkgRes = await Nova.api('packages', 'list');
+ const pkgOpts = (pkgRes?.data || []).map(p => `
`).join('');
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ }
+
+ 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('users', 'list', { params:{ role: 'reseller' }});
+ const rows = res?.data || [];
+ return `
+
+
+
+ ${rows.length ? `
| Username | Email | Status | Created | Actions |
+ ${rows.map(r => `
+ | ${Nova.escHtml(r.username)} |
+ ${Nova.escHtml(r.email||'—')} |
+ ${Nova.badge(r.status, r.status==='active'?'green':'red')} |
+ ${r.created_at ? r.created_at.slice(0,10) : '—'} |
+
+
+ ${r.status === 'active'
+ ? ``
+ : ``}
+
+ |
+
`).join('')}
+
`
+ : '
No resellers yet.
'}
+
+
`;
+ }
+
+ window.adminAddReseller = () => {
+ Nova.modal('Create Reseller Account', `
+
+
+
`,
+ `
`);
+ };
+
+ window.adminResellerPasswd = (id, user) => {
+ Nova.modal(`Change Password — ${user}`,
+ `
`,
+ `
`);
+ };
+
+ window.adminResellerSuspend = (id, user) => {
+ Nova.confirm(`Suspend reseller ${user}?`, async () => {
+ const r = await Nova.api('users','suspend',{method:'POST',body:{id}});
+ if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); }
+ else Nova.toast(r?.message||'Error','error');
+ });
+ };
+
+ window.adminResellerUnsuspend = async (id) => {
+ const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}});
+ if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); }
+ else Nova.toast(r?.message||'Error','error');
+ };
+
+ window.adminResellerDelete = (id, user) => {
+ Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => {
+ const r = await Nova.api('users','delete',{method:'POST',body:{id}});
+ if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); }
+ else Nova.toast(r?.message||'Error','error');
+ }, true);
+ };
+
+ // ── 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} |
+
+
+
+ |
+
`).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', `
+
`,
+ `
`);
+ }
+ 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} |
+
+
+
+ |
+
`).join('')}
+
`
+ : '
No DNS zones yet.
'}
+
`;
+ }
+
+ window.adminAddZone = () => {
+ Nova.modal('Create DNS Zone', `
`,
+ `
`);
+ };
+ window.adminEditZone = async (id, domain) => {
+ const res = await Nova.api('dns', 'records', {params:{zone_id:id}});
+ if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
+ const records = Array.isArray(res.data) ? res.data : [];
+ const rows = records.map(r => `
| ${Nova.escHtml(r.name)} | ${Nova.badge(r.type,'default')} | ${Nova.escHtml(r.content||r.value||'')} | ${r.ttl||3600} |
+ |
`).join('');
+ Nova.modal(`DNS: ${domain}`, `
+
+
| Name | Type | Content | TTL | |
${rows||'| No records yet. |
'}
`);
+ };
+ window.adminAddRecord = (zoneId, domain) => {
+ Nova.modal('Add Record', `
+
+
+
+
`,
+ `
`);
+ };
+ window.adminDelRecord = async (id, zoneId, domain) => {
+ Nova.confirm('Delete this record?', async () => {
+ const r = await Nova.api('dns','delete-record',{method:'POST',body:{id}});
+ if (r?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); adminEditZone(zoneId,domain); }
+ else Nova.toast(r?.message,'error');
+ }, true);
+ };
+ window.adminDelZone = (id, domain) => {
+ Nova.confirm(`Delete DNS zone for ${domain}?`, async () => {
+ const r = await Nova.api('dns','delete-zone',{method:'POST',body:{zone_id:id}});
+ if (r?.success) { Nova.toast('Zone deleted','success'); adminPage('dns-zones'); }
+ else Nova.toast(r?.message,'error');
+ }, true);
+ };
+
+ // ── Nameservers ────────────────────────────────────────────────────────────
+ async function nameservers() {
+ const r = await Nova.api('server_setup','get');
+ const d = r?.data || {};
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ }
+ 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 [statsR, phpR] = await Promise.all([
+ Nova.api('system','stats'),
+ Nova.api('php','global-config'),
+ ]);
+ const svcs = statsR?.data?.services || {};
+ const uptime = statsR?.data?.uptime || '—';
+ const cpu = statsR?.data?.cpu?.pct ?? '—';
+ const ram = statsR?.data?.ram?.pct ?? '—';
+ const phpCfg = phpR?.data || {};
+
+ window.wsLoadLog = async (log) => {
+ const r = await Nova.api('system','read-log',{params:{log}});
+ const el = document.getElementById('ws-log-out');
+ if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; }
+ };
+
+ return `
+
+
+
+
+
+
PHP
${phpCfg.version||'8.3'}
+
+
+
+
+
+
+ ${Object.entries(svcs).map(([s,st]) => `
+
+
${s} ${Nova.badge(st,st==='active'?'green':'red')}
+
+
+
+
+
+
`).join('')}
+
+
+
+
+
+ ${[['Version',phpCfg.version||'8.3'],['Memory Limit',phpCfg.memory_limit||'256M'],
+ ['Upload Max',phpCfg.upload_max_filesize||'64M'],['Max Exec Time',(phpCfg.max_execution_time||30)+'s'],
+ ['Post Max',phpCfg.post_max_size||'64M']].map(([k,v])=>`
+
+ ${k}${v}
+
`).join('')}
+
+
+
+
+
+
+
← Click a log above to view it
+
`;
}
// ── SSL Manager ────────────────────────────────────────────────────────────