From 3ca3a1dae6e22ec0dfd6522f41036a94cd367601 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 22 Jun 2026 19:13:06 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20surgical=20dashboard=20merge=20=E2=80=94?= =?UTF-8?q?=20only=20remove=20serverStatus(),=20keep=20all=20other=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous merge accidentally deleted 38KB of page functions (accounts, packages, DNS, etc.) by using wrong boundary. This time only removes the serverStatus() function body. Dashboard now includes history chart + setTimeout to render it. All other pages intact. --- panel/public/assets/js/admin.js | 1275 ++++++++++++++++++++++++++++++- 1 file changed, 1233 insertions(+), 42 deletions(-) diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index fc81de7..c79394e 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -75,7 +75,7 @@ // ── Page definitions ─────────────────────────────────────────────────────── const pages = { dashboard, - 'server-status': dashboard, + 'server-status': serverStatus, accounts, resellers, packages, @@ -124,24 +124,12 @@ document.getElementById('server-ip').textContent = ''; - setTimeout(() => { - const canvas = document.getElementById('dash-stats-chart'); - if (!canvas || !hist.length) return; - const initChart = () => initStatsChart(canvas, hist); - if (!window.Chart) { - const sc = document.createElement('script'); - sc.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'; - sc.onload = initChart; - document.head.appendChild(sc); - } else { initChart(); } - }, 150); - return ` -
+
CPU Usage
${s.cpu?.pct ?? 0}%
-
Load: ${(s.cpu?.load || [0,0,0]).map(n=>n.toFixed(2)).join(' / ')}
+
Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}
${Nova.progressBar(s.cpu?.pct || 0)}
@@ -153,22 +141,19 @@
Disk
${s.disk?.pct ?? 0}%
-
${Nova.bytes((s.disk?.total||0)-(s.disk?.free||0))} used of ${Nova.bytes(s.disk?.total||0)}
+
${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used
${Nova.progressBar(s.disk?.pct || 0)}
Uptime
-
${s.uptime || '—'}
-
PHP ${v.php_version || '—'} · v${v.installed_version || '—'}
+
${s.uptime || '—'}
+
PHP ${v.php_version || '—'}
-
+
-
- Services - -
+
Services
${Object.entries(s.services || {}).map(([svc, status]) => ` @@ -185,34 +170,32 @@
-
NovaCPX
+
NovaCPX Version
- + - + - +
Version${v.installed_version || '—'}
Installed${v.installed_version || '—'}
Branch${v.git_branch || 'main'}
Commit${(v.git_commit||'—').substring(0,8)}${v.git_dirty ? ' dirty' : ''}
Commit${v.git_commit || '—'}${v.git_dirty ? ' dirty' : ''}
PHP${v.php_version || '—'}
OS${v.os || '—'}
OS${v.os || '—'}
- +
-
-
- 24-Hour History - ${hist.length} samples -
+
+
24-Hour History${hist.length} samples
- ${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 = ` + + + +
+
+ + + NovaCPX Panel + + ${ncpxCount > 0 ? Nova.badge(ncpxCount + ' commit' + (ncpxCount > 1 ? 's' : '') + ' available', 'yellow') : Nova.badge('Up to date', 'green')} + +
+
+
+

Installed

${v.installed_version || '—'}

+

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

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

+

Channel

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

PHP

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

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

+ + ` : `

NovaCPX is up to date.

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

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

+ + ` : `

All OS packages are current.

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

No tracked services found.

'; return; } + const statusDot = s => s === 'active' + ? '● running' + : s === null ? '' + : '● stopped'; + body.innerHTML = `
+ + ${svcs.map(s => ` + + + + + + + `).join('')} +
ServiceDescriptionInstalledLatestStatusState
${Nova.escHtml(s.label)}
${Nova.escHtml(s.pkg)}
${Nova.escHtml(s.desc)}${Nova.escHtml(s.installed)}${Nova.escHtml(s.latest)}${s.up_to_date === true ? Nova.badge('current','green') : s.up_to_date === false ? Nova.badge('update available','yellow') : ''}${statusDot(s.status)}
`; + }; + + // ── Audit Log ────────────────────────────────────────────────────────────── + async function auditLog(opts = {}) { + const { page = 1, user = '', action = '', date_from = '', date_to = '' } = opts; + const params = { page, per_page: 50 }; + if (user) params.user = user; + if (action) params.action = action; + if (date_from) params.date_from = date_from; + if (date_to) params.date_to = date_to; + + const content = document.getElementById('page-content'); + const filterBar = ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
`; + + if (content) content.innerHTML = filterBar + '
Loading…
'; + + const res = await Nova.api('system', 'audit-log', { params }); + const rows = res?.data || []; + const meta = res?.meta || {}; + const total = meta.total || rows.length; + const pages = meta.pages || 1; + + const tableHtml = rows.length ? ` +
+ + + + ${rows.map((r, i) => ` + + + + + + + + + + + `).join('')} + +
TimeUserActionResourceIP
${Nova.relTime(r.created_at)}${Nova.escHtml(r.username || '—')}${Nova.escHtml(r.action)}${Nova.escHtml(r.resource || '—')}${Nova.escHtml(r.ip_address || '—')}
+
` : '

No audit entries match the current filters.

'; + + const paginationHtml = pages > 1 ? ` +
+ ${Array.from({length: pages}, (_, i) => i + 1).map(p => ` + + `).join('')} +
` : ''; + + const tableCard = ` +
+
+ Audit Log + ${total} entr${total !== 1 ? 'ies' : 'y'} +
+ ${tableHtml} + ${paginationHtml} +
`; + + if (content) content.innerHTML = filterBar + tableCard; + else return filterBar + tableCard; + + window._alOpts = opts; + } + + window.alToggleDetail = (i) => { + const row = document.getElementById('al-detail-' + i); + if (row) row.style.display = row.style.display === 'none' ? '' : 'none'; + }; + window.alApplyFilter = () => { + auditLog({ + page: 1, + user: document.getElementById('al-user')?.value || '', + action: document.getElementById('al-action')?.value || '', + date_from: document.getElementById('al-from')?.value || '', + date_to: document.getElementById('al-to')?.value || '', + }); + }; + window.alGoPage = (p) => auditLog({ ...(window._alOpts || {}), page: p }); + + // ── PHP Manager ──────────────────────────────────────────────────────────── + async function phpManager() { + const res = await Nova.api('php', 'versions'); + const data = res?.data || {}; + const vers = data.versions || []; + const panelPhp = data.panel_php || '—'; + + return ` + + +
+
Panel PHP
+
+

NovaCPX itself runs on PHP ${panelPhp} (always the highest installed version, updated automatically when a new version is installed).

+
+
+ +
+
Installed Versions
+
+
+ ${vers.map(v => ` +
+
+ PHP ${v.version} + ${v.installed ? Nova.badge(v.fpm_active ? 'active' : 'stopped', v.fpm_active ? 'green' : 'yellow') : Nova.badge('not installed','muted')} +
+ ${v.is_default ? `
Panel default
` : ''} +
+ ${v.installed ? ` + + + ${!v.is_default ? `` : ''} + ` : ` + + `} +
+
`).join('')} +
+
+
+ +`; + } + + window.phpInstallVersion = (ver) => { + Nova.confirm(`Install PHP ${ver}? This will run apt-get and may take a minute.`, async () => { + Nova.loading(`Installing PHP ${ver}…`); + const r = await Nova.api('php', 'install-version', { method: 'POST', body: { version: ver } }); + Nova.loadingDone(); + if (r?.success) { Nova.toast(`PHP ${ver} installed`, 'success'); adminPage('php-manager'); } + else Nova.toast(r?.message || 'Install failed', 'error'); + }); + }; + + window.phpRemoveVersion = (ver) => { + Nova.confirm(`Remove PHP ${ver}? All FPM pools for this version will stop.`, async () => { + Nova.loading(`Removing PHP ${ver}…`); + const r = await Nova.api('php', 'remove-version', { method: 'POST', body: { version: ver } }); + Nova.loadingDone(); + if (r?.success) { Nova.toast(`PHP ${ver} removed`, 'success'); adminPage('php-manager'); } + else Nova.toast(r?.message || 'Remove failed', 'error'); + }, true); + }; + + window.phpFpmAction = async (ver, cmd) => { + Nova.loading(`${cmd} php${ver}-fpm…`); + const r = await Nova.api('php', 'fpm-action', { method: 'POST', body: { version: ver, command: cmd } }); + Nova.loadingDone(); + if (r?.success) { Nova.toast(r.message, 'success'); refreshSvcStatus(`php${ver}-fpm`); } + else Nova.toast(r?.message || 'Action failed', 'error'); + }; + + window.phpExtModal = async (ver) => { + const panel = document.getElementById('php-ext-panel'); + if (!panel) return; + panel.style.display = ''; + panel.innerHTML = `

Loading extensions for PHP ${ver}…

`; + panel.scrollIntoView({ behavior: 'smooth' }); + + const r = await Nova.api('php', 'version-extensions', { params: { version: ver } }); + if (!r?.success) { panel.innerHTML = `

${r?.message || 'Failed to load'}

`; return; } + + const installed = r.data.installed || []; + const available = r.data.available || []; + const notInstalled = available.filter(pkg => { + const ext = pkg.replace(/^php[\d.]+-/, ''); + return !installed.some(i => i.toLowerCase() === ext.toLowerCase() || i.toLowerCase().replace('_','-') === ext.toLowerCase()); + }); + + panel.innerHTML = ` +
+
+ PHP ${ver} Extensions +
+ + +
+
+
+
+ Add extension +
+ + or + + +
+
+
+ + + + ${installed.map(e => ` + + + + `).join('')} + +
ExtensionAction
${e} + +
+
+
+
`; + }; + + window.phpExtFilter = (q) => { + document.querySelectorAll('.php-ext-row').forEach(row => { + row.style.display = row.dataset.ext.includes(q.toLowerCase()) ? '' : 'none'; + }); + }; + + const _phpExtStream = (ver, ext, action) => { + const termId = 'phpext-term-' + Date.now(); + const verb = action === 'install-extension' ? 'Installing' : 'Removing'; + Nova.modal(`${verb} ${ext} (PHP ${ver})`, ` +
+ Starting…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + fetch(`/api/php/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: ver, extension: ext }), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[done]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.done) { + const btn = document.getElementById('phpext-close'); + if (btn) { + btn.textContent = obj.success ? 'Done ✓' : 'Close'; + btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost'; + btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); phpExtModal(ver); }; + } + } + } catch(e) {} } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\nFetch error: ${err.message}`)); + }; + + window.phpExtInstall = (ver) => { + const sel = document.getElementById('php-ext-add-sel')?.value; + const custom = document.getElementById('php-ext-add-custom')?.value?.trim(); + const ext = custom || sel; + if (!ext) { Nova.toast('Choose or type an extension name', 'error'); return; } + _phpExtStream(ver, ext, 'install-extension'); + }; + + window.phpExtRemove = (ver, ext) => { + Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, () => { + _phpExtStream(ver, ext, 'remove-extension'); + }, true); + }; + + // ── Notifications (#25) ─────────────────────────────────────────────────── + async function notifications() { + const res = await Nova.api('system', 'notify-settings'); + const s = res?.data || {}; + setTimeout(etLoadList, 80); + return ` + + +
+
CyberMail Settings
+
+
+
+
+ + + Leave blank to keep existing key. Get your key from platform.cyberpersons.com +
+
+ + +
+
+ + +
+
+ + + Receives alerts for new accounts, suspensions, disk warnings +
+
+ + +
+
+
+ + +
+
+
+
+ +
+
+ Email Templates + +
+
+
Loading templates…
+
+
`; + } + + document.addEventListener('submit', async e => { + if (!e.target.matches('#notify-form')) return; + e.preventDefault(); + const fd = new FormData(e.target); + const body = Object.fromEntries(fd.entries()); + if (!body.cybermail_api_key) delete body.cybermail_api_key; + const res = await Nova.api('system', 'save-notify-settings', { method: 'POST', body }); + if (res?.success) Nova.toast('Notification settings saved', 'success'); + else Nova.toast(res?.message || 'Save failed', 'error'); + }); + + window.notifyTest = async () => { + const email = prompt('Send test email to:'); + if (!email) return; + const res = await Nova.api('system', 'test-notify', { method: 'POST', body: { to: email } }); + if (res?.success) Nova.toast(res.message, 'success'); + else Nova.toast(res?.message || 'Send failed', 'error'); + }; + + // ── Email Template Management ────────────────────────────────────────────── + window.etLoadList = async () => { + const body = document.getElementById('et-list-body'); + if (!body) return; + body.innerHTML = '
Loading templates…
'; + const r = await Nova.api('system', 'email-templates'); + const tmpls = r?.data?.templates || []; + if (!tmpls.length) { + body.innerHTML = '

No templates found. Create one.

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