Fix service switching, dynamic dashboard services, DB engine manager

- save-option: inline service switching (web/ftp/dns) instead of missing shell scripts
- stats: dynamic service list based on web_server/ftp_server/dns_server settings
- service action: allow all variants (nginx, pure-ftpd, pdns, nsd, etc.)
- mysqlManager: full rewrite with MySQL/MariaDB/PostgreSQL engine cards (install/remove/start/stop), active engine selector, all-databases table
- ftpServer page: dynamic — shows whichever FTP server is configured, not hardcoded proftpd
- db-engine-action: fixed duplicate INSERT line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:41:49 +00:00
parent 5251494f7a
commit 5ef458dfb0
2 changed files with 210 additions and 30 deletions
+105 -8
View File
@@ -302,9 +302,16 @@ match ($action) {
$diskFree = disk_free_space('/'); $diskFree = disk_free_space('/');
$diskPct = $diskTotal > 0 ? round((($diskTotal - $diskFree) / $diskTotal) * 100, 1) : 0; $diskPct = $diskTotal > 0 ? round((($diskTotal - $diskFree) / $diskTotal) * 100, 1) : 0;
// Services // Services — dynamic list based on configured servers
$webSetting = $db->fetchOne("SELECT `value` FROM settings WHERE `key`='web_server'")['value'] ?? 'apache';
$ftpSetting = $db->fetchOne("SELECT `value` FROM settings WHERE `key`='ftp_server'")['value'] ?? 'proftpd';
$dnsSetting = $db->fetchOne("SELECT `value` FROM settings WHERE `key`='dns_server'")['value'] ?? 'bind9';
$webSvc = match($webSetting) { 'nginx' => 'nginx', 'apache' => 'apache2', default => 'apache2' };
$ftpSvc = match($ftpSetting) { 'vsftpd' => 'vsftpd', 'pureftpd' => 'pure-ftpd', default => 'proftpd' };
$dnsSvc = match($dnsSetting) { 'powerdns' => 'pdns', 'nsd' => 'nsd', 'none' => null, default => 'named' };
$svcList = array_filter([$webSvc,'mysql','postfix','dovecot',$ftpSvc,$dnsSvc,'fail2ban']);
$services = []; $services = [];
foreach (['apache2','nginx','mysql','postfix','dovecot','proftpd','named','fail2ban'] as $svc) { foreach ($svcList as $svc) {
$active = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: ''); $active = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: '');
if ($active) $services[$svc] = $active; if ($active) $services[$svc] = $active;
} }
@@ -329,7 +336,9 @@ match ($action) {
Auth::getInstance()->require('admin'); Auth::getInstance()->require('admin');
$svc = preg_replace('/[^a-z0-9\-_]/', '', $body['service'] ?? ''); $svc = preg_replace('/[^a-z0-9\-_]/', '', $body['service'] ?? '');
$cmd = $body['command'] ?? 'status'; $cmd = $body['command'] ?? 'status';
$allowed = ['apache2','nginx','mysql','postfix','dovecot','proftpd','named','fail2ban','php7.4-fpm','php8.1-fpm','php8.2-fpm','php8.3-fpm']; $allowed = ['apache2','nginx','lighttpd','caddy','mysql','mariadb','postgresql','postfix','dovecot',
'proftpd','vsftpd','pure-ftpd','named','bind9','pdns','nsd','fail2ban',
'php7.4-fpm','php8.1-fpm','php8.2-fpm','php8.3-fpm'];
if (!in_array($svc, $allowed)) Response::error("Service not managed: $svc"); if (!in_array($svc, $allowed)) Response::error("Service not managed: $svc");
if (!in_array($cmd, ['start','stop','restart','reload','status'])) Response::error("Invalid command"); if (!in_array($cmd, ['start','stop','restart','reload','status'])) Response::error("Invalid command");
@@ -390,15 +399,31 @@ match ($action) {
$value = $body['value'] ?? ''; $value = $body['value'] ?? '';
$allowed = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname']; $allowed = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname'];
if (!in_array($key, $allowed)) Response::error("Invalid setting key: $key"); if (!in_array($key, $allowed)) Response::error("Invalid setting key: $key");
// Save before switching so the new value is in DB
$db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$key, $value]); $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$key, $value]);
// For server switches, run install/reload scripts // Inline service switching — stop all alternatives, start the chosen one
if (in_array($key, ['web_server','ftp_server','dns_server','mail_server'])) { if ($key === 'web_server') {
$script = "/opt/novacpx/bin/switch-{$key}.sh"; $webSvcs = ['apache2','nginx','lighttpd','caddy'];
if (is_executable($script)) { foreach ($webSvcs as $s) { shell_exec("systemctl stop $s 2>/dev/null; systemctl disable $s 2>/dev/null"); }
shell_exec("sudo {$script} " . escapeshellarg($value) . " > /var/log/novacpx/switch-{$key}.log 2>&1 &"); $startSvc = match($value) { 'nginx' => 'nginx', 'apache' => 'apache2', default => 'apache2' };
shell_exec("systemctl enable $startSvc 2>/dev/null && systemctl start $startSvc 2>/dev/null");
} elseif ($key === 'ftp_server') {
foreach (['proftpd','vsftpd','pure-ftpd'] as $s) { shell_exec("systemctl stop $s 2>/dev/null; systemctl disable $s 2>/dev/null"); }
$startSvc = match($value) { 'vsftpd' => 'vsftpd', 'pureftpd' => 'pure-ftpd', default => 'proftpd' };
if (trim(shell_exec("dpkg -l $startSvc 2>/dev/null | grep -c '^ii'") ?: '0') > 0) {
shell_exec("systemctl enable $startSvc 2>/dev/null && systemctl start $startSvc 2>/dev/null");
}
} elseif ($key === 'dns_server') {
foreach (['named','bind9','pdns','nsd'] as $s) { shell_exec("systemctl stop $s 2>/dev/null; systemctl disable $s 2>/dev/null"); }
if ($value !== 'none') {
$startSvc = match($value) { 'powerdns' => 'pdns', 'nsd' => 'nsd', default => 'named' };
shell_exec("systemctl enable $startSvc 2>/dev/null && systemctl start $startSvc 2>/dev/null");
} }
} }
// mail_server: postfix + dovecot are always running; mail_server setting controls config template only
audit("settings.{$key}", $value); audit("settings.{$key}", $value);
Response::success(null, "Setting saved: {$key} = {$value}"); Response::success(null, "Setting saved: {$key} = {$value}");
})(), })(),
@@ -471,5 +496,77 @@ match ($action) {
else Response::error("CyberMail returned HTTP {$code}: " . substr($resp, 0, 200)); else Response::error("CyberMail returned HTTP {$code}: " . substr($resp, 0, 200));
})(), })(),
// ── Database engine management ────────────────────────────────────────────
'db-engines' => (function() use ($db) {
Auth::getInstance()->require('admin');
$engines = [];
foreach (['mysql','mariadb','postgresql'] as $eng) {
$svc = match($eng) {
'mysql' => 'mysql',
'mariadb' => 'mariadb',
'postgresql' => 'postgresql',
};
$active = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: '');
$enabled = trim(shell_exec("systemctl is-enabled $svc 2>/dev/null") ?: '');
$pkgCheck = match($eng) {
'mysql' => 'dpkg -l mysql-server 2>/dev/null | grep -c "^ii"',
'mariadb' => 'dpkg -l mariadb-server 2>/dev/null | grep -c "^ii"',
'postgresql' => 'dpkg -l postgresql 2>/dev/null | grep -c "^ii"',
};
$installed = (int)trim(shell_exec($pkgCheck) ?: '0') > 0;
$version = '';
if ($installed) {
$version = match($eng) {
'mysql' => trim(shell_exec("mysql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+'") ?: ''),
'mariadb' => trim(shell_exec("mariadb --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+'") ?: ''),
'postgresql' => trim(shell_exec("psql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+'") ?: ''),
};
}
$engines[$eng] = [
'installed' => $installed,
'active' => $active === 'active',
'enabled' => $enabled === 'enabled',
'version' => $version,
];
}
$active_db = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'active_db_engine'")['value'] ?? 'mysql';
Response::success(['engines' => $engines, 'active_engine' => $active_db]);
})(),
'db-engine-action' => (function() use ($db, $body) {
Auth::getInstance()->require('admin');
$engine = preg_replace('/[^a-z]/', '', $body['engine'] ?? '');
$action = $body['action'] ?? '';
if (!in_array($engine, ['mysql','mariadb','postgresql'])) Response::error("Invalid engine");
if (!in_array($action, ['install','remove','start','stop','restart','set-active'])) Response::error("Invalid action");
$out = '';
if ($action === 'install') {
$pkg = match($engine) {
'mysql' => 'mysql-server',
'mariadb' => 'mariadb-server',
'postgresql' => 'postgresql postgresql-contrib',
};
$out = shell_exec("DEBIAN_FRONTEND=noninteractive apt-get install -y $pkg 2>&1");
shell_exec("systemctl enable $engine && systemctl start $engine 2>/dev/null");
} elseif ($action === 'remove') {
$pkg = match($engine) {
'mysql' => 'mysql-server mysql-client',
'mariadb' => 'mariadb-server mariadb-client',
'postgresql' => 'postgresql postgresql-contrib',
};
shell_exec("systemctl stop $engine 2>/dev/null || true");
$out = shell_exec("apt-get remove -y $pkg 2>&1");
} elseif ($action === 'set-active') {
$db->execute("INSERT INTO settings (`key`,`value`) VALUES ('active_db_engine',?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$engine]);
audit('settings.active_db_engine', $engine);
Response::success(null, "Active database engine set to $engine");
} else {
shell_exec("systemctl $action $engine 2>/dev/null");
}
audit("db-engine.$action", $engine);
Response::success(['output' => substr($out ?: '', -1000)], ucfirst($action) . " $engine done");
})(),
default => Response::error("Unknown system action: $action", 404), default => Response::error("Unknown system action: $action", 404),
}; };
+102 -19
View File
@@ -1597,24 +1597,81 @@ ${ips.length ? `
// ── MySQL/DB Manager ─────────────────────────────────────────────────────── // ── MySQL/DB Manager ───────────────────────────────────────────────────────
async function mysqlManager() { async function mysqlManager() {
const res = await Nova.api('databases','list',{params:{account_id:0}}); const [engRes, dbRes] = await Promise.all([
const dbs = res?.data || []; Nova.api('system','db-engines'),
Nova.api('databases','list',{params:{account_id:0}}),
]);
const eng = engRes?.data?.engines || {};
const actE = engRes?.data?.active_engine || 'mysql';
const dbs = dbRes?.data || [];
const engineCard = (id, label, icon) => {
const e = eng[id] || {};
const statusColor = e.active ? 'green' : (e.installed ? 'red' : 'default');
const statusText = !e.installed ? 'Not Installed' : (e.active ? 'Running' : 'Stopped');
return ` return `
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title">Databases</span></div> <div class="card-header">
${dbs.length ? `<table class="table"><thead><tr><th>Database</th><th>User</th><th>Type</th><th>Account</th><th>Size</th><th>Actions</th></tr></thead><tbody> <span class="card-title">${icon} ${label}</span>
${Nova.badge(statusText, statusColor)}
${e.version ? `<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">v${e.version}</span>` : ''}
</div>
<div class="card-body">
<div style="display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.75rem">
${!e.installed
? `<button class="btn btn-xs btn-primary" onclick="dbEngineAction('${id}','install')">Install</button>`
: `
<button class="btn btn-xs" onclick="dbEngineAction('${id}','start')">Start</button>
<button class="btn btn-xs" onclick="dbEngineAction('${id}','stop')">Stop</button>
<button class="btn btn-xs" onclick="dbEngineAction('${id}','restart')">Restart</button>
<button class="btn btn-xs btn-danger" onclick="dbEngineAction('${id}','remove')">Remove</button>`
}
</div>
${e.installed && id !== 'postgresql' ? `<a href="http://${location.hostname}/phpmyadmin" target="_blank" class="btn btn-xs btn-ghost">phpMyAdmin ↗</a>` : ''}
${e.installed && id === 'postgresql' ? `<a href="http://${location.hostname}/pgadmin" target="_blank" class="btn btn-xs btn-ghost">pgAdmin ↗</a>` : ''}
</div>
</div>`;
};
const dbTable = dbs.length ? `
<table class="table"><thead><tr><th>Database</th><th>User</th><th>Type</th><th>Account</th><th>Actions</th></tr></thead><tbody>
${dbs.map(d=>`<tr> ${dbs.map(d=>`<tr>
<td><strong>${d.db_name}</strong></td> <td><strong>${Nova.escHtml(d.db_name)}</strong></td>
<td>${d.db_user}</td> <td>${Nova.escHtml(d.db_user||'—')}</td>
<td>${Nova.badge(d.db_type,'default')}</td> <td>${Nova.badge(d.db_type||'mysql','default')}</td>
<td>${d.username||'—'}</td> <td>${Nova.escHtml(d.username||'—')}</td>
<td>${d.size||'—'}</td> <td><button class="btn btn-xs btn-danger" onclick="adminDropDB(${d.id},'${Nova.escHtml(d.db_name)}')">Drop</button></td>
<td><button class="btn btn-xs btn-danger" onclick="adminDropDB(${d.id},'${d.db_name}')">Drop</button></td>
</tr>`).join('')} </tr>`).join('')}
</tbody></table>` </tbody></table>` : '<div class="empty" style="padding:2rem">No databases yet.</div>';
: '<div class="empty" style="padding:2rem">No databases.</div>'}
return `
<div class="page-header"><h2 class="page-title">Database Engine Manager</h2></div>
<div class="grid-3 gap-2" style="margin-bottom:1.5rem">
${engineCard('mysql', 'MySQL', '🐬')}
${engineCard('mariadb', 'MariaDB', '🦭')}
${engineCard('postgresql','PostgreSQL','🐘')}
</div>
<div class="card" style="margin-bottom:1.5rem">
<div class="card-header"><span class="card-title">Active Engine</span><span class="text-muted" style="font-size:.82rem">Used for new account databases</span></div>
<div class="card-body" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<select id="db-active-engine" class="form-control" style="max-width:200px">
<option value="mysql" ${actE==='mysql'?'selected':''}>MySQL</option>
<option value="mariadb" ${actE==='mariadb'?'selected':''}>MariaDB</option>
<option value="postgresql" ${actE==='postgresql'?'selected':''}>PostgreSQL</option>
</select>
<button class="btn btn-primary btn-sm" onclick="dbSetActive()">Set Active</button>
<span class="text-muted" style="font-size:.82rem">Currently: ${Nova.badge(actE,'green')}</span>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">All Databases</span><span class="text-muted" style="font-size:.8rem">${dbs.length} total</span></div>
${dbTable}
</div>`; </div>`;
} }
window.adminDropDB = (id, name) => { window.adminDropDB = (id, name) => {
Nova.confirm(`Drop database ${name}? ALL DATA WILL BE LOST.`, async () => { Nova.confirm(`Drop database ${name}? ALL DATA WILL BE LOST.`, async () => {
const r = await Nova.api('databases','drop',{method:'POST',body:{id}}); const r = await Nova.api('databases','drop',{method:'POST',body:{id}});
@@ -1622,6 +1679,25 @@ ${ips.length ? `
else Nova.toast(r?.message,'error'); else Nova.toast(r?.message,'error');
}, true); }, true);
}; };
window.dbEngineAction = (engine, action) => {
const labels = {install:`Installing ${engine}`,remove:`Removing ${engine}`,start:`Starting…`,stop:`Stopping…`,restart:`Restarting…`};
const doIt = async () => {
Nova.toast(labels[action]||'Working…','info');
const r = await Nova.api('system','db-engine-action',{method:'POST',body:{engine,action}});
Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error');
if (r?.success) adminPage('mysql-manager');
};
if (['install','remove'].includes(action)) {
Nova.confirm(`${action === 'install' ? 'Install' : 'Remove'} ${engine}?`, doIt, action === 'remove');
} else { doIt(); }
};
window.dbSetActive = async () => {
const engine = document.getElementById('db-active-engine')?.value;
if (!engine) return;
const r = await Nova.api('system','db-engine-action',{method:'POST',body:{engine,action:'set-active'}});
Nova.toast(r?.message||(r?.success?'Active engine updated':'Failed'), r?.success?'success':'error');
if (r?.success) adminPage('mysql-manager');
};
// ── Mail Server ──────────────────────────────────────────────────────────── // ── Mail Server ────────────────────────────────────────────────────────────
async function mailServer() { async function mailServer() {
@@ -1660,21 +1736,28 @@ ${ips.length ? `
// ── FTP Server ──────────────────────────────────────────────────────────── // ── FTP Server ────────────────────────────────────────────────────────────
async function ftpServer() { async function ftpServer() {
const r = await Nova.api('system','stats'); const [sRes, optsRes] = await Promise.all([
const ftpStatus = r?.data?.services?.proftpd || 'unknown'; Nova.api('system','stats'),
Nova.api('system','server-options'),
]);
const svcs = sRes?.data?.services || {};
const ftpConf = optsRes?.data?.ftp_server || 'proftpd';
const svcName = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'pure-ftpd' : 'proftpd');
const label = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'Pure-FTPd' : 'ProFTPD');
const status = svcs[svcName] || 'unknown';
return ` return `
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title">FTP Server (ProFTPD)</span> <span class="card-title">FTP Server (${label})</span>
${Nova.badge(ftpStatus, ftpStatus==='active'?'green':'red')} ${Nova.badge(status, status==='active'?'green':'red')}
<div style="display:flex;gap:.5rem;margin-left:auto"> <div style="display:flex;gap:.5rem;margin-left:auto">
<button class="btn btn-sm" onclick="adminServiceAction('proftpd','restart')">Restart</button> <button class="btn btn-sm" onclick="adminServiceAction('${svcName}','restart')">Restart</button>
<button class="btn btn-sm" onclick="adminServiceAction('proftpd','reload')">Reload</button> <button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')">Reload</button>
</div> </div>
</div> </div>
<div style="padding:1.25rem"> <div style="padding:1.25rem">
<div style="color:var(--muted);font-size:.85rem"> <div style="color:var(--muted);font-size:.85rem">
<p>ProFTPD uses virtual users stored in <code>/etc/proftpd/novacpx-users.passwd</code></p> <p>Active FTP server: <strong>${label}</strong> — change in <a href="#" onclick="adminPage('server-options')">Server Options</a>.</p>
<p style="margin-top:.5rem">FTP connections use SFTP on port 22 or passive FTP on ports 20/21.</p> <p style="margin-top:.5rem">FTP connections use SFTP on port 22 or passive FTP on ports 20/21.</p>
<p style="margin-top:.5rem">Per-account FTP management is available in each account's FTP page.</p> <p style="margin-top:.5rem">Per-account FTP management is available in each account's FTP page.</p>
</div> </div>