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
+59 -62
View File
@@ -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);
$panelPorts = [PORT_USER, PORT_RESELLER, PORT_ADMIN];
$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';
// 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');
}
// 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");
$backupDir = '/var/novacpx/backups/pre-os-update-' . date('YmdHis');
// 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");
$sh = <<<BASH
#!/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 &");
// 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");
}
}
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");
}
// 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 ─────────────────────────────────────────────────
+50 -23
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++;
}
@@ -1881,21 +1881,48 @@ ${dbs.map(d=>`<tr>
};
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);
});
};
@@ -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 `
<div class="page-header mb-3">
<h2 class="page-title">Two-Factor Authentication</h2>
@@ -2934,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>
+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);
};