mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
Fix accounts list display, OS update terminal modal
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -111,76 +111,73 @@ match ($action) {
|
|||||||
]);
|
]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
// ── Apply OS update ───────────────────────────────────────────────────────
|
// ── Start OS update (background job) ─────────────────────────────────────
|
||||||
'apply-os-update' => (function() use ($db) {
|
'apply-os-update' => (function() use ($db) {
|
||||||
Auth::getInstance()->require('admin');
|
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];
|
$sh = <<<BASH
|
||||||
$webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2';
|
#!/bin/bash
|
||||||
|
exec > {$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
|
audit('system.os-update-start', $jobId);
|
||||||
$beforeServices = [];
|
Response::success(['job_id' => $jobId]);
|
||||||
foreach ([$webSvc, 'mysql', 'postfix', 'dovecot', 'proftpd', 'named'] as $svc) {
|
})(),
|
||||||
$beforeServices[$svc] = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: 'unknown');
|
|
||||||
|
// ── 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
|
Response::success(['lines' => $lines, 'done' => $done, 'exit_code' => $exitCode]);
|
||||||
$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),
|
|
||||||
]);
|
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
// ── Check NovaCPX update ─────────────────────────────────────────────────
|
// ── Check NovaCPX update ─────────────────────────────────────────────────
|
||||||
|
|||||||
+50
-23
@@ -724,7 +724,7 @@
|
|||||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||||
async function accounts() {
|
async function accounts() {
|
||||||
const res = await Nova.api('accounts', 'list');
|
const res = await Nova.api('accounts', 'list');
|
||||||
const accts = res?.data?.accounts || [];
|
const accts = res?.data || [];
|
||||||
window._adminAccts = accts;
|
window._adminAccts = accts;
|
||||||
return `
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
window.adminSearchAccounts = async (q) => {
|
window.adminSearchAccounts = async (q) => {
|
||||||
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
|
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
|
||||||
const el = document.getElementById('admin-acct-table');
|
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) => {
|
window.adminSuspend = async (id, user) => {
|
||||||
Nova.confirm(`Suspend ${user}?`, async () => {
|
Nova.confirm(`Suspend ${user}?`, async () => {
|
||||||
@@ -843,7 +843,7 @@
|
|||||||
// ── Resellers ──────────────────────────────────────────────────────────────
|
// ── Resellers ──────────────────────────────────────────────────────────────
|
||||||
async function resellers() {
|
async function resellers() {
|
||||||
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
|
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
|
||||||
const rows = res?.data?.accounts || [];
|
const rows = res?.data || [];
|
||||||
return `
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -1101,7 +1101,7 @@
|
|||||||
Nova.toast('Queuing SSL for all domains without certificates…','info',6000);
|
Nova.toast('Queuing SSL for all domains without certificates…','info',6000);
|
||||||
const accts = await Nova.api('accounts','list',{params:{limit:1000}});
|
const accts = await Nova.api('accounts','list',{params:{limit:1000}});
|
||||||
let count = 0;
|
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}});
|
await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain}});
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -1881,21 +1881,48 @@ ${dbs.map(d=>`<tr>
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.applyOSUpdate = async () => {
|
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.confirm('Apply OS package upgrades? Services will be automatically restarted if needed.', async () => {
|
||||||
Nova.loading('Running OS upgrade — this may take a few minutes…');
|
const startRes = await Nova.api('system', 'apply-os-update', { method: 'POST' });
|
||||||
const res = await Nova.api('system', 'apply-os-update', { method: 'POST', timeout: 120000 });
|
if (!startRes?.success) {
|
||||||
Nova.loadingDone();
|
Nova.toast(startRes?.message || 'Failed to start upgrade', 'error');
|
||||||
if (res?.data) {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
const jobId = startRes.data.job_id;
|
||||||
|
|
||||||
|
const ov = Nova.modal('OS Update Progress',
|
||||||
|
`<div id="os-term" style="background:#0d1117;color:#c9d1d9;font-family:monospace;font-size:.8rem;line-height:1.5;padding:1rem;height:340px;overflow-y:auto;border-radius:6px;white-space:pre-wrap;word-break:break-word">Starting upgrade…</div>`,
|
||||||
|
`<span id="os-upd-status" style="color:var(--text-muted);font-size:.85rem">Running…</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-ghost" id="os-close-btn" disabled onclick="this.closest('.modal-overlay').remove()">Close</button>`
|
||||||
|
);
|
||||||
|
|
||||||
|
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('accounts','list',{params:{limit:500}}),
|
||||||
Nova.api('wordpress','list'),
|
Nova.api('wordpress','list'),
|
||||||
]);
|
]);
|
||||||
const accts = acctRes?.data?.accounts || [];
|
const accts = acctRes?.data || [];
|
||||||
const installs = wpRes?.data?.installs || [];
|
const installs = wpRes?.data?.installs || [];
|
||||||
window._adminAcctsWP = accts;
|
window._adminAcctsWP = accts;
|
||||||
|
|
||||||
@@ -2090,7 +2117,7 @@ async function backupsFull() {
|
|||||||
Nova.api('accounts','list',{params:{limit:500}}),
|
Nova.api('accounts','list',{params:{limit:500}}),
|
||||||
Nova.api('backup','list'),
|
Nova.api('backup','list'),
|
||||||
]);
|
]);
|
||||||
const accts = acctRes?.data?.accounts || [];
|
const accts = acctRes?.data || [];
|
||||||
const backupList = bkRes?.data?.backups || [];
|
const backupList = bkRes?.data?.backups || [];
|
||||||
const diskUsed = bkRes?.data?.disk_used || 0;
|
const diskUsed = bkRes?.data?.disk_used || 0;
|
||||||
window._adminAcctsBK = accts;
|
window._adminAcctsBK = accts;
|
||||||
@@ -2264,7 +2291,7 @@ window.bkSaveScheduleFor = async (id) => {
|
|||||||
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
|
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
|
||||||
async function cloudflarePage() {
|
async function cloudflarePage() {
|
||||||
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
|
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
|
||||||
const accts = acctRes?.data?.accounts || [];
|
const accts = acctRes?.data || [];
|
||||||
window._adminAcctsCF = accts;
|
window._adminAcctsCF = accts;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -2413,7 +2440,7 @@ window.cfPurge = async (zoneId, acctId) => {
|
|||||||
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
|
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
|
||||||
async function twofaPage() {
|
async function twofaPage() {
|
||||||
const res = await Nova.api('accounts','list',{params:{limit:500}});
|
const res = await Nova.api('accounts','list',{params:{limit:500}});
|
||||||
const users = res?.data?.accounts || [];
|
const users = res?.data || [];
|
||||||
return `
|
return `
|
||||||
<div class="page-header mb-3">
|
<div class="page-header mb-3">
|
||||||
<h2 class="page-title">Two-Factor Authentication</h2>
|
<h2 class="page-title">Two-Factor Authentication</h2>
|
||||||
@@ -2934,7 +2961,7 @@ ${stacks.map(s=>`<tr>
|
|||||||
|
|
||||||
} else if (tab === 'quotas') {
|
} else if (tab === 'quotas') {
|
||||||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||||
const users = r?.data?.accounts || [];
|
const users = r?.data || [];
|
||||||
tc.innerHTML = `
|
tc.innerHTML = `
|
||||||
<p class="text-muted" style="margin-bottom:1rem">Set Docker resource limits per user. Click a row to edit.</p>
|
<p class="text-muted" style="margin-bottom:1rem">Set Docker resource limits per user. Click a row to edit.</p>
|
||||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
||||||
|
|||||||
@@ -724,7 +724,7 @@
|
|||||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||||
async function accounts() {
|
async function accounts() {
|
||||||
const res = await Nova.api('accounts', 'list');
|
const res = await Nova.api('accounts', 'list');
|
||||||
const accts = res?.data?.accounts || [];
|
const accts = res?.data || [];
|
||||||
window._adminAccts = accts;
|
window._adminAccts = accts;
|
||||||
return `
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
window.adminSearchAccounts = async (q) => {
|
window.adminSearchAccounts = async (q) => {
|
||||||
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
|
const res = await Nova.api('accounts', 'list', { params: q ? { search: q } : {}});
|
||||||
const el = document.getElementById('admin-acct-table');
|
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) => {
|
window.adminSuspend = async (id, user) => {
|
||||||
Nova.confirm(`Suspend ${user}?`, async () => {
|
Nova.confirm(`Suspend ${user}?`, async () => {
|
||||||
@@ -843,7 +843,7 @@
|
|||||||
// ── Resellers ──────────────────────────────────────────────────────────────
|
// ── Resellers ──────────────────────────────────────────────────────────────
|
||||||
async function resellers() {
|
async function resellers() {
|
||||||
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
|
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
|
||||||
const rows = res?.data?.accounts || [];
|
const rows = res?.data || [];
|
||||||
return `
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -1101,7 +1101,7 @@
|
|||||||
Nova.toast('Queuing SSL for all domains without certificates…','info',6000);
|
Nova.toast('Queuing SSL for all domains without certificates…','info',6000);
|
||||||
const accts = await Nova.api('accounts','list',{params:{limit:1000}});
|
const accts = await Nova.api('accounts','list',{params:{limit:1000}});
|
||||||
let count = 0;
|
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}});
|
await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain}});
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -1857,33 +1857,72 @@ ${dbs.map(d=>`<tr>
|
|||||||
Nova.loading('Pulling NovaCPX update from GitHub…');
|
Nova.loading('Pulling NovaCPX update from GitHub…');
|
||||||
const res = await Nova.api('system', 'apply-novacpx-update', { method: 'POST' });
|
const res = await Nova.api('system', 'apply-novacpx-update', { method: 'POST' });
|
||||||
Nova.loadingDone();
|
Nova.loadingDone();
|
||||||
if (res?.data?.updated) {
|
const d = res?.data;
|
||||||
Nova.toast(`Updated to ${res.data.to_commit}`, 'success', 6000);
|
if (!res?.success) {
|
||||||
setTimeout(() => Nova.loadPage('updates', pages), 2000);
|
Nova.modal('Update Failed', `<p class="text-danger">${Nova.escHtml(res?.message || 'Unknown error')}</p>`);
|
||||||
} else if (res?.error) {
|
return;
|
||||||
Nova.toast(res.error, 'error', 8000);
|
}
|
||||||
|
if (d?.updated) {
|
||||||
|
const steps = (d.steps || []).map(s => `<div>${Nova.escHtml(s)}</div>`).join('');
|
||||||
|
Nova.modal('Update Complete',
|
||||||
|
`<p><strong>Updated:</strong> <code>${Nova.escHtml(d.from_commit)}</code> → <code>${Nova.escHtml(d.to_commit)}</code></p>
|
||||||
|
${steps ? `<div class="terminal mt-2" style="max-height:200px;overflow-y:auto;font-size:.78rem">${steps}</div>` : ''}
|
||||||
|
<p class="text-muted mt-2 text-sm">Backup saved to: <code>${Nova.escHtml(d.backup_path || '')}</code></p>`,
|
||||||
|
`<button class="btn btn-primary" onclick="this.closest('.modal-overlay').remove();adminPage('updates')">OK</button>`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
Nova.toast('Already up to date.', 'info');
|
Nova.modal('Already Up To Date',
|
||||||
|
`<p>NovaCPX is already at the latest commit: <code>${Nova.escHtml(d?.to_commit || '—')}</code></p>
|
||||||
|
${d?.pull_output ? `<div class="terminal mt-2" style="font-size:.78rem">${Nova.escHtml(d.pull_output)}</div>` : ''}`,
|
||||||
|
`<button class="btn btn-primary" onclick="this.closest('.modal-overlay').remove();adminPage('updates')">OK</button>`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
window.applyOSUpdate = async () => {
|
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.confirm('Apply OS package upgrades? Services will be automatically restarted if needed.', async () => {
|
||||||
Nova.loading('Running OS upgrade — this may take a few minutes…');
|
const startRes = await Nova.api('system', 'apply-os-update', { method: 'POST' });
|
||||||
const res = await Nova.api('system', 'apply-os-update', { method: 'POST', timeout: 120000 });
|
if (!startRes?.success) {
|
||||||
Nova.loadingDone();
|
Nova.toast(startRes?.message || 'Failed to start upgrade', 'error');
|
||||||
if (res?.data) {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
const jobId = startRes.data.job_id;
|
||||||
|
|
||||||
|
const ov = Nova.modal('OS Update Progress',
|
||||||
|
`<div id="os-term" style="background:#0d1117;color:#c9d1d9;font-family:monospace;font-size:.8rem;line-height:1.5;padding:1rem;height:340px;overflow-y:auto;border-radius:6px;white-space:pre-wrap;word-break:break-word">Starting upgrade…</div>`,
|
||||||
|
`<span id="os-upd-status" style="color:var(--text-muted);font-size:.85rem">Running…</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-ghost" id="os-close-btn" disabled onclick="this.closest('.modal-overlay').remove()">Close</button>`
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1953,7 +1992,7 @@ async function wordpressPage() {
|
|||||||
Nova.api('accounts','list',{params:{limit:500}}),
|
Nova.api('accounts','list',{params:{limit:500}}),
|
||||||
Nova.api('wordpress','list'),
|
Nova.api('wordpress','list'),
|
||||||
]);
|
]);
|
||||||
const accts = acctRes?.data?.accounts || [];
|
const accts = acctRes?.data || [];
|
||||||
const installs = wpRes?.data?.installs || [];
|
const installs = wpRes?.data?.installs || [];
|
||||||
window._adminAcctsWP = accts;
|
window._adminAcctsWP = accts;
|
||||||
|
|
||||||
@@ -2078,7 +2117,7 @@ async function backupsFull() {
|
|||||||
Nova.api('accounts','list',{params:{limit:500}}),
|
Nova.api('accounts','list',{params:{limit:500}}),
|
||||||
Nova.api('backup','list'),
|
Nova.api('backup','list'),
|
||||||
]);
|
]);
|
||||||
const accts = acctRes?.data?.accounts || [];
|
const accts = acctRes?.data || [];
|
||||||
const backupList = bkRes?.data?.backups || [];
|
const backupList = bkRes?.data?.backups || [];
|
||||||
const diskUsed = bkRes?.data?.disk_used || 0;
|
const diskUsed = bkRes?.data?.disk_used || 0;
|
||||||
window._adminAcctsBK = accts;
|
window._adminAcctsBK = accts;
|
||||||
@@ -2252,7 +2291,7 @@ window.bkSaveScheduleFor = async (id) => {
|
|||||||
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
|
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
|
||||||
async function cloudflarePage() {
|
async function cloudflarePage() {
|
||||||
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
|
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
|
||||||
const accts = acctRes?.data?.accounts || [];
|
const accts = acctRes?.data || [];
|
||||||
window._adminAcctsCF = accts;
|
window._adminAcctsCF = accts;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -2401,7 +2440,7 @@ window.cfPurge = async (zoneId, acctId) => {
|
|||||||
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
|
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
|
||||||
async function twofaPage() {
|
async function twofaPage() {
|
||||||
const res = await Nova.api('accounts','list',{params:{limit:500}});
|
const res = await Nova.api('accounts','list',{params:{limit:500}});
|
||||||
const users = res?.data?.accounts || [];
|
const users = res?.data || [];
|
||||||
return `
|
return `
|
||||||
<div class="page-header mb-3">
|
<div class="page-header mb-3">
|
||||||
<h2 class="page-title">Two-Factor Authentication</h2>
|
<h2 class="page-title">Two-Factor Authentication</h2>
|
||||||
@@ -2922,7 +2961,7 @@ ${stacks.map(s=>`<tr>
|
|||||||
|
|
||||||
} else if (tab === 'quotas') {
|
} else if (tab === 'quotas') {
|
||||||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||||
const users = r?.data?.accounts || [];
|
const users = r?.data || [];
|
||||||
tc.innerHTML = `
|
tc.innerHTML = `
|
||||||
<p class="text-muted" style="margin-bottom:1rem">Set Docker resource limits per user. Click a row to edit.</p>
|
<p class="text-muted" style="margin-bottom:1rem">Set Docker resource limits per user. Click a row to edit.</p>
|
||||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
||||||
@@ -3073,11 +3112,12 @@ async function serverOptions() {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title">Web Server</span>${Nova.badge(opts.web_server||'apache','green')}</div>
|
<div class="card-header"><span class="card-title">Web Server</span>${Nova.badge(opts.web_server||'apache','green')}</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted" style="font-size:.85rem;margin-bottom:1rem">Current web server for hosting accounts. Changing requires migration of all vhosts.</p>
|
<p class="text-muted" style="font-size:.85rem;margin-bottom:.5rem">Controls which server handles customer sites on ports 80/443. The panel itself always runs on Apache (ports 8880–8883) regardless of this setting.</p>
|
||||||
|
<p class="text-muted" style="font-size:.85rem;margin-bottom:1rem">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')}</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Active Web Server</label>
|
<label>Customer Hosting Web Server</label>
|
||||||
<select id="so-web" class="form-control">
|
<select id="so-web" class="form-control">
|
||||||
${['apache','nginx','openlitespeed','caddy'].map(s=>`<option value="${s}" ${s===(opts.web_server||'apache')?'selected':''}>${s.charAt(0).toUpperCase()+s.slice(1)}</option>`).join('')}
|
${['apache','nginx'].map(s=>`<option value="${s}" ${s===(opts.web_server||'apache')?'selected':''}>${s.charAt(0).toUpperCase()+s.slice(1)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" onclick="soSave('web_server','so-web','Web server')">Save & Switch</button>
|
<button class="btn btn-primary btn-sm" onclick="soSave('web_server','so-web','Web server')">Save & Switch</button>
|
||||||
@@ -3198,10 +3238,12 @@ async function serverOptions() {
|
|||||||
window.soSave = async (key, inputId, label) => {
|
window.soSave = async (key, inputId, label) => {
|
||||||
const val = document.getElementById(inputId)?.value;
|
const val = document.getElementById(inputId)?.value;
|
||||||
if (!val) return;
|
if (!val) return;
|
||||||
Nova.confirm(`Switch ${label} to "${val}"? This will run install/migration scripts on the server.`, async () => {
|
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 } });
|
const r = await Nova.api('system', 'save-option', { method:'POST', body:{ key, value: val } });
|
||||||
Nova.toast(r?.success ? `${label} updated` : (r?.message||'Failed'), r?.success?'success':'error');
|
Nova.loadingDone();
|
||||||
if (r?.success) Nova.loadPage('server-options', window._novaPages);
|
Nova.toast(r?.success ? `${label} switched to ${val}` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||||
|
if (r?.success) adminPage('server-options');
|
||||||
}, true);
|
}, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user