From af9f1b8f434f583844818a0ac2c7de0cdc12cc2d Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 8 Jun 2026 20:24:22 +0000 Subject: [PATCH] Fix accounts list display, OS update terminal modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix accounts list always showing empty: Response::paginate() returns data as res.data (array), not res.data.accounts — fix all 9 call sites in admin.js - Replace blocking apply-os-update with background job + terminal modal: start-os-update runs apt-get as nohup subprocess with sudo, writes to /tmp log file; os-update-status polls log and done-file; admin.js shows scrolling terminal modal that auto-closes when complete - Fix OS update: was running apt-get without sudo (www-data lacks root) Co-Authored-By: Claude Sonnet 4.6 --- panel/api/endpoints/system.php | 125 ++++++++++++++++---------------- panel/assets/js/admin.js | 73 +++++++++++++------ panel/public/assets/js/admin.js | 112 +++++++++++++++++++--------- 3 files changed, 188 insertions(+), 122 deletions(-) diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index 483fd13..bd736b0 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -111,76 +111,73 @@ match ($action) { ]); })(), - // ── Apply OS update ─────────────────────────────────────────────────────── + // ── Start OS update (background job) ───────────────────────────────────── 'apply-os-update' => (function() use ($db) { Auth::getInstance()->require('admin'); - set_time_limit(300); + $jobId = bin2hex(random_bytes(8)); + $logFile = "/tmp/ncpx-os-update-{$jobId}.log"; + $doneFile = "/tmp/ncpx-os-update-{$jobId}.done"; + $script = "/tmp/ncpx-os-update-{$jobId}.sh"; + $webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2'; + $webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public'; + $backupDir = '/var/novacpx/backups/pre-os-update-' . date('YmdHis'); - $panelPorts = [PORT_USER, PORT_RESELLER, PORT_ADMIN]; - $webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2'; + $sh = << {$logFile} 2>&1 +echo "[$(date -u +%H:%M:%S UTC)] Preparing backup..." +mkdir -p {$backupDir} +cp -a {$webRoot} {$backupDir}/public 2>/dev/null +echo "[$(date -u +%H:%M:%S UTC)] Updating package lists..." +sudo apt-get update -q +echo "[$(date -u +%H:%M:%S UTC)] Running upgrade (non-interactive)..." +DEBIAN_FRONTEND=noninteractive sudo apt-get upgrade -y \\ + -o Dpkg::Options::="--force-confdef" \\ + -o Dpkg::Options::="--force-confold" +UPGRADE_EXIT=\$? +echo "[$(date -u +%H:%M:%S UTC)] Checking services..." +for SVC in {$webSvc} mysql postfix dovecot; do + if systemctl is-active --quiet \$SVC 2>/dev/null; then :; else + echo "[$(date -u +%H:%M:%S UTC)] Restarting \$SVC..." + sudo systemctl restart \$SVC 2>/dev/null && echo " \$SVC restarted OK" || echo " \$SVC restart FAILED" + fi +done +if [ \$UPGRADE_EXIT -eq 0 ]; then + echo "[$(date -u +%H:%M:%S UTC)] Upgrade complete." +else + echo "[$(date -u +%H:%M:%S UTC)] Upgrade finished with errors (exit code \$UPGRADE_EXIT)." +fi +echo \$UPGRADE_EXIT > {$doneFile} +BASH; + file_put_contents($script, $sh); + chmod($script, 0755); + shell_exec("nohup " . escapeshellarg($script) . " > /dev/null 2>&1 &"); - // Snapshot service states before upgrade - $beforeServices = []; - foreach ([$webSvc, 'mysql', 'postfix', 'dovecot', 'proftpd', 'named'] as $svc) { - $beforeServices[$svc] = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: 'unknown'); + audit('system.os-update-start', $jobId); + Response::success(['job_id' => $jobId]); + })(), + + // ── Poll OS update job status ───────────────────────────────────────────── + 'os-update-status' => (function() { + Auth::getInstance()->require('admin'); + $jobId = preg_replace('/[^a-f0-9]/', '', $_GET['job_id'] ?? ''); + if (!$jobId) Response::error('job_id required'); + + $logFile = "/tmp/ncpx-os-update-{$jobId}.log"; + $doneFile = "/tmp/ncpx-os-update-{$jobId}.done"; + + $content = @file_get_contents($logFile) ?: ''; + $lines = $content !== '' ? explode("\n", rtrim($content)) : []; + $done = file_exists($doneFile); + $exitCode = $done ? (int)trim(@file_get_contents($doneFile) ?: '1') : null; + + if ($done) { + @unlink($logFile); + @unlink($doneFile); + @unlink("/tmp/ncpx-os-update-{$jobId}.sh"); } - // Backup panel web root - $backupDir = '/var/novacpx/backups/pre-os-update-' . date('YmdHis'); - $webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public'; - shell_exec("mkdir -p " . escapeshellarg($backupDir)); - shell_exec("cp -a " . escapeshellarg($webRoot) . " " . escapeshellarg("$backupDir/public") . " 2>&1"); - - // Run upgrade (non-interactive, hold back kernel packages to avoid reboot surprise) - $env = 'DEBIAN_FRONTEND=noninteractive'; - $opts = '-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"'; - $out = shell_exec("$env apt-get upgrade -y -q $opts 2>&1"); - - // Self-healing: restart any service that went down - $healed = []; - sleep(3); - foreach ($beforeServices as $svc => $wasBefore) { - if ($wasBefore !== 'active') continue; - $nowState = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: ''); - if ($nowState !== 'active') { - shell_exec("sudo systemctl restart $svc 2>/dev/null"); - sleep(2); - $afterHeal = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: ''); - $healed[$svc] = $afterHeal === 'active' ? 'restarted' : 'FAILED'; - if ($afterHeal !== 'active') { - novacpx_log('error', "Self-heal FAILED for $svc after OS upgrade"); - } - } - } - - // Verify panel ports respond - $panelOk = []; - foreach ($panelPorts as $port) { - $proto = in_array($port, [PORT_ADMIN, PORT_RESELLER, PORT_USER]) ? 'https' : 'http'; - $ch = curl_init("{$proto}://127.0.0.1:{$port}/"); - curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_TIMEOUT => 5]); - curl_exec($ch); - $panelOk[$port] = curl_getinfo($ch, CURLINFO_HTTP_CODE) > 0; - curl_close($ch); - } - $panelDown = array_keys(array_filter($panelOk, fn($ok) => !$ok)); - - // If panel ports down, restore from backup and restart web server - if ($panelDown) { - shell_exec("cp -a " . escapeshellarg("$backupDir/public") . " " . escapeshellarg($webRoot) . " 2>&1"); - shell_exec("sudo systemctl restart $webSvc 2>/dev/null"); - novacpx_log('error', 'Panel ports down after OS upgrade — restored from backup'); - } - - audit('system.os-update', "upgraded; healed:" . implode(',', array_keys($healed))); - Response::success([ - 'upgraded' => true, - 'panel_ports_ok' => empty($panelDown), - 'panel_ports_down' => $panelDown, - 'services_healed' => $healed, - 'backup_path' => $backupDir, - 'upgrade_output' => substr($out ?: '', -2000), - ]); + Response::success(['lines' => $lines, 'done' => $done, 'exit_code' => $exitCode]); })(), // ── Check NovaCPX update ───────────────────────────────────────────────── diff --git a/panel/assets/js/admin.js b/panel/assets/js/admin.js index 00e5d4b..aca1082 100644 --- a/panel/assets/js/admin.js +++ b/panel/assets/js/admin.js @@ -724,7 +724,7 @@ // ── Accounts ─────────────────────────────────────────────────────────────── async function accounts() { const res = await Nova.api('accounts', 'list'); - const accts = res?.data?.accounts || []; + const accts = res?.data || []; window._adminAccts = accts; return `
@@ -766,7 +766,7 @@ 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?.accounts || []); + if (el) el.innerHTML = renderAccountTable(res?.data || []); }; window.adminSuspend = async (id, user) => { Nova.confirm(`Suspend ${user}?`, async () => { @@ -843,7 +843,7 @@ // ── Resellers ────────────────────────────────────────────────────────────── async function resellers() { const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }}); - const rows = res?.data?.accounts || []; + const rows = res?.data || []; return `
@@ -1101,7 +1101,7 @@ 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?.accounts || [])) { + for (const a of (accts?.data || [])) { await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain}}); count++; } @@ -1881,21 +1881,48 @@ ${dbs.map(d=>` }; window.applyOSUpdate = async () => { - Nova.confirm('Apply OS package upgrades? Services will be automatically restarted if needed. The NovaCPX panel will self-restore from backup if any ports go down.', async () => { - Nova.loading('Running OS upgrade — this may take a few minutes…'); - const res = await Nova.api('system', 'apply-os-update', { method: 'POST', timeout: 120000 }); - Nova.loadingDone(); - if (res?.data) { - const d = res.data; - const healed = Object.entries(d.services_healed || {}).map(([s,r]) => `${s}: ${r}`).join(', '); - let msg = 'OS upgrade complete.'; - if (healed) msg += ` Auto-healed: ${healed}.`; - if (!d.panel_ports_ok) msg += ' ⚠ Panel ports were down — auto-restored from backup.'; - Nova.toast(msg, d.panel_ports_ok ? 'success' : 'warning', 10000); - Nova.loadPage('updates', pages); - } else { - Nova.toast(res?.error || 'Upgrade failed', 'error', 8000); + Nova.confirm('Apply OS package upgrades? Services will be automatically restarted if needed.', async () => { + const startRes = await Nova.api('system', 'apply-os-update', { method: 'POST' }); + if (!startRes?.success) { + Nova.toast(startRes?.message || 'Failed to start upgrade', 'error'); + return; } + const jobId = startRes.data.job_id; + + const ov = Nova.modal('OS Update Progress', + `
Starting upgrade…
`, + `Running… +
+ ` + ); + + const term = document.getElementById('os-term'); + const statusEl = document.getElementById('os-upd-status'); + const closeBtn = document.getElementById('os-close-btn'); + + const poll = async () => { + const r = await Nova.api('system', 'os-update-status', { params: { job_id: jobId } }); + if (!r?.success) { + if (term) term.textContent += '\n[Error reading job status]'; + if (statusEl) statusEl.textContent = 'Error'; + if (closeBtn) closeBtn.disabled = false; + return; + } + if (term) { + term.textContent = (r.data.lines || []).join('\n') || 'Waiting for output…'; + term.scrollTop = term.scrollHeight; + } + if (r.data.done) { + const ok = r.data.exit_code === 0; + if (statusEl) { statusEl.textContent = ok ? 'Complete' : `Failed (exit ${r.data.exit_code})`; statusEl.style.color = ok ? 'var(--success,#22c55e)' : 'var(--error,#ef4444)'; } + if (closeBtn) closeBtn.disabled = false; + Nova.toast(ok ? 'OS upgrade complete' : 'OS upgrade finished with errors — see log', ok ? 'success' : 'error', 8000); + } else { + setTimeout(poll, 2000); + } + }; + + setTimeout(poll, 2000); }); }; @@ -1965,7 +1992,7 @@ async function wordpressPage() { Nova.api('accounts','list',{params:{limit:500}}), Nova.api('wordpress','list'), ]); - const accts = acctRes?.data?.accounts || []; + const accts = acctRes?.data || []; const installs = wpRes?.data?.installs || []; window._adminAcctsWP = accts; @@ -2090,7 +2117,7 @@ async function backupsFull() { Nova.api('accounts','list',{params:{limit:500}}), Nova.api('backup','list'), ]); - const accts = acctRes?.data?.accounts || []; + const accts = acctRes?.data || []; const backupList = bkRes?.data?.backups || []; const diskUsed = bkRes?.data?.disk_used || 0; window._adminAcctsBK = accts; @@ -2264,7 +2291,7 @@ window.bkSaveScheduleFor = async (id) => { // ── Cloudflare Integration (#16) ────────────────────────────────────────── async function cloudflarePage() { const acctRes = await Nova.api('accounts','list',{params:{limit:500}}); - const accts = acctRes?.data?.accounts || []; + const accts = acctRes?.data || []; window._adminAcctsCF = accts; return ` @@ -2413,7 +2440,7 @@ window.cfPurge = async (zoneId, acctId) => { // ── TOTP / 2FA Admin (#17) ──────────────────────────────────────────────── async function twofaPage() { const res = await Nova.api('accounts','list',{params:{limit:500}}); - const users = res?.data?.accounts || []; + const users = res?.data || []; return `