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:
2026-06-08 20:24:22 +00:00
parent d587ad4ebd
commit af9f1b8f43
3 changed files with 188 additions and 122 deletions
+77 -35
View File
@@ -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 `
<div class="card">
@@ -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 `
<div class="card">
<div class="card-header">
@@ -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++;
}
@@ -1857,33 +1857,72 @@ ${dbs.map(d=>`<tr>
Nova.loading('Pulling NovaCPX update from GitHub…');
const res = await Nova.api('system', 'apply-novacpx-update', { method: 'POST' });
Nova.loadingDone();
if (res?.data?.updated) {
Nova.toast(`Updated to ${res.data.to_commit}`, 'success', 6000);
setTimeout(() => Nova.loadPage('updates', pages), 2000);
} else if (res?.error) {
Nova.toast(res.error, 'error', 8000);
const d = res?.data;
if (!res?.success) {
Nova.modal('Update Failed', `<p class="text-danger">${Nova.escHtml(res?.message || 'Unknown error')}</p>`);
return;
}
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 {
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 () => {
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',
`<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('wordpress','list'),
]);
const accts = acctRes?.data?.accounts || [];
const accts = acctRes?.data || [];
const installs = wpRes?.data?.installs || [];
window._adminAcctsWP = accts;
@@ -2078,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;
@@ -2252,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 `
@@ -2401,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 `
<div class="page-header mb-3">
<h2 class="page-title">Two-Factor Authentication</h2>
@@ -2922,7 +2961,7 @@ ${stacks.map(s=>`<tr>
} else if (tab === 'quotas') {
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
const users = r?.data?.accounts || [];
const users = r?.data || [];
tc.innerHTML = `
<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>
@@ -3073,11 +3112,12 @@ async function serverOptions() {
<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-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 88808883) 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')} &nbsp; Nginx: ${opts.nginx_active ? Nova.badge('active','green') : Nova.badge('inactive','red')}</p>
<div class="form-group">
<label>Active Web Server</label>
<label>Customer Hosting Web Server</label>
<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>
</div>
<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) => {
const val = document.getElementById(inputId)?.value;
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 } });
Nova.toast(r?.success ? `${label} updated` : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) Nova.loadPage('server-options', window._novaPages);
Nova.loadingDone();
Nova.toast(r?.success ? `${label} switched to ${val}` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('server-options');
}, true);
};