diff --git a/db/migrations/007_stats_settings.sql b/db/migrations/007_stats_settings.sql new file mode 100644 index 0000000..3f554d0 --- /dev/null +++ b/db/migrations/007_stats_settings.sql @@ -0,0 +1,22 @@ +-- Migration 007: server_stats table + server type settings + WHMCS key +CREATE TABLE IF NOT EXISTS server_stats ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + cpu_usage DECIMAL(5,2) DEFAULT 0, + ram_usage DECIMAL(5,2) DEFAULT 0, + disk_usage DECIMAL(5,2) DEFAULT 0, + load_avg DECIMAL(8,2) DEFAULT 0, + net_in_kb BIGINT DEFAULT 0, + net_out_kb BIGINT DEFAULT 0, + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX (recorded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO settings (`key`, `value`) VALUES + ('web_server', 'apache'), + ('mail_server', 'postfix-dovecot'), + ('ftp_server', 'proftpd'), + ('dns_server', 'bind9'), + ('whmcs_api_key', ''), + ('whmcs_enabled', '0'), + ('ns1_hostname', ''), + ('ns2_hostname', ''); diff --git a/panel/api/endpoints/databases.php b/panel/api/endpoints/databases.php index 7acf811..de9bcc3 100644 --- a/panel/api/endpoints/databases.php +++ b/panel/api/endpoints/databases.php @@ -19,6 +19,12 @@ match ($action) { 'create' => (function() use ($db, $body, $accountId) { if (!$accountId) Response::error("account_id required"); + // Package limit check + $acctPkg = $db->fetchOne("SELECT p.max_databases FROM accounts a LEFT JOIN packages p ON p.id=a.package_id WHERE a.id=?", [$accountId]); + if ($acctPkg && $acctPkg['max_databases'] > 0) { + $count = (int)$db->fetchOne("SELECT COUNT(*) c FROM databases WHERE account_id=?", [$accountId])['c']; + if ($count >= (int)$acctPkg['max_databases']) Response::error("Database limit ({$acctPkg['max_databases']}) reached for this package", 403); + } $type = $body['type'] ?? 'mysql'; $dbName = trim($body['db_name'] ?? ''); $dbUser = trim($body['db_user'] ?? $dbName . '_user'); diff --git a/panel/api/endpoints/dns.php b/panel/api/endpoints/dns.php index 375e524..e38021f 100644 --- a/panel/api/endpoints/dns.php +++ b/panel/api/endpoints/dns.php @@ -97,5 +97,29 @@ match ($action) { Response::success(['domain' => $domain, 'results' => $results]); })(), + // ── NS Health Checker (#22e) ───────────────────────────────────────────── + 'ns-health' => (function() use ($db) { + Auth::getInstance()->require('admin'); + $ns1 = $db->fetchOne("SELECT value FROM settings WHERE `key`='ns1_hostname'")['value'] ?? ''; + $ns2 = $db->fetchOne("SELECT value FROM settings WHERE `key`='ns2_hostname'")['value'] ?? ''; + $zones = $db->fetchAll("SELECT domain FROM dns_zones ORDER BY domain LIMIT 200"); + $results = []; + foreach ($zones as $z) { + $domain = $z['domain']; + $raw = shell_exec("dig +short NS " . escapeshellarg($domain) . " 2>/dev/null") ?? ''; + $nsRecords = array_filter(array_map('trim', explode("\n", $raw))); + $foundNs1 = $ns1 ? in_array(rtrim($ns1,'.').'.', array_map(fn($n)=>rtrim($n,'.').'.', $nsRecords)) : null; + $foundNs2 = $ns2 ? in_array(rtrim($ns2,'.').'.', array_map(fn($n)=>rtrim($n,'.').'.', $nsRecords)) : null; + $results[] = [ + 'domain' => $domain, + 'ns1' => $nsRecords[0] ?? null, + 'ns2' => $nsRecords[1] ?? null, + 'ok' => ($ns1 ? $foundNs1 : true) && ($ns2 ? $foundNs2 : true), + 'ns_raw' => $nsRecords, + ]; + } + Response::success(['results' => $results, 'expected_ns1' => $ns1, 'expected_ns2' => $ns2]); + })(), + default => Response::error("Unknown dns action: $action", 404), }; diff --git a/panel/api/endpoints/email.php b/panel/api/endpoints/email.php index 06e5195..aaaa8aa 100644 --- a/panel/api/endpoints/email.php +++ b/panel/api/endpoints/email.php @@ -30,6 +30,12 @@ match ($action) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address"); if (strlen($password) < 6) Response::error("Password must be at least 6 characters"); if (!$accountId) Response::error("account_id required"); + // Package limit check + $acctPkg = $db->fetchOne("SELECT p.max_email FROM accounts a LEFT JOIN packages p ON p.id=a.package_id WHERE a.id=?", [$accountId]); + if ($acctPkg && $acctPkg['max_email'] > 0) { + $count = (int)$db->fetchOne("SELECT COUNT(*) c FROM email_accounts WHERE account_id=?", [$accountId])['c']; + if ($count >= (int)$acctPkg['max_email']) Response::error("Email account limit ({$acctPkg['max_email']}) reached for this package", 403); + } $id = EmailManager::createAccount($accountId, $email, $password, $quota); audit('email.create', $email); Response::success(['id' => $id], "Email account created: $email"); diff --git a/panel/api/endpoints/ftp.php b/panel/api/endpoints/ftp.php index 27c1fe6..7607593 100644 --- a/panel/api/endpoints/ftp.php +++ b/panel/api/endpoints/ftp.php @@ -15,6 +15,12 @@ match ($action) { 'create' => (function() use ($db, $body, $accountId) { if (!$accountId) Response::error("account_id required"); + // Package limit check + $acctPkg = $db->fetchOne("SELECT p.max_ftp FROM accounts a LEFT JOIN packages p ON p.id=a.package_id WHERE a.id=?", [$accountId]); + if ($acctPkg && $acctPkg['max_ftp'] > 0) { + $count = (int)$db->fetchOne("SELECT COUNT(*) c FROM ftp_accounts WHERE account_id=?", [$accountId])['c']; + if ($count >= (int)$acctPkg['max_ftp']) Response::error("FTP account limit ({$acctPkg['max_ftp']}) reached for this package", 403); + } $username = trim($body['username'] ?? ''); $password = $body['password'] ?? bin2hex(random_bytes(6)); $acct = $db->fetchOne("SELECT home_dir FROM accounts WHERE id = ?", [$accountId]); diff --git a/panel/api/endpoints/stats.php b/panel/api/endpoints/stats.php index 21598ee..13b9c9e 100644 --- a/panel/api/endpoints/stats.php +++ b/panel/api/endpoints/stats.php @@ -76,17 +76,17 @@ match ($action) { $pkg = $db->fetchOne("SELECT * FROM packages WHERE id = ?", [$acct['package_id'] ?? 0]); Response::success([ - 'disk_mb' => $diskMB, - 'disk_limit' => $pkg['disk_mb'] ?? 0, - 'inodes' => $inodes, - 'databases' => $dbCount, - 'db_limit' => $pkg['databases'] ?? 0, - 'emails' => $emailCount, - 'email_limit' => $pkg['email_accounts'] ?? 0, - 'ftp' => $ftpCount, - 'ftp_limit' => $pkg['ftp_accounts'] ?? 0, - 'domains' => $domCount, - 'subdomain_limit' => $pkg['subdomains'] ?? 0, + 'disk_mb' => $diskMB, + 'disk_limit' => $pkg['disk_mb'] ?? 0, + 'inodes' => $inodes, + 'databases' => $dbCount, + 'db_limit' => $pkg['max_databases'] ?? 0, + 'emails' => $emailCount, + 'email_limit' => $pkg['max_email'] ?? 0, + 'ftp' => $ftpCount, + 'ftp_limit' => $pkg['max_ftp'] ?? 0, + 'domains' => $domCount, + 'subdomain_limit' => $pkg['max_subdomains'] ?? 0, ]); })(), diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index a853880..ec05830 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -349,5 +349,43 @@ match ($action) { Response::paginate($rows, (int)$total, $page, $perPage); })(), + // ── Server Options (#22a-e) ─────────────────────────────────────────────── + 'server-options' => (function() use ($db) { + Auth::getInstance()->require('admin'); + $keys = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname']; + $opts = []; + foreach ($db->fetchAll("SELECT `key`,`value` FROM settings WHERE `key` IN ('" . implode("','", $keys) . "')") as $r) { + $opts[$r['key']] = $r['value']; + } + // Detect actually-running services + $opts['apache_active'] = !empty(trim(shell_exec('systemctl is-active apache2 2>/dev/null') ?: '')) && trim(shell_exec('systemctl is-active apache2 2>/dev/null')) === 'active'; + $opts['nginx_active'] = trim(shell_exec('systemctl is-active nginx 2>/dev/null') ?: '') === 'active'; + $opts['proftpd_active'] = trim(shell_exec('systemctl is-active proftpd 2>/dev/null') ?: '') === 'active'; + $opts['vsftpd_active'] = trim(shell_exec('systemctl is-active vsftpd 2>/dev/null') ?: '') === 'active'; + $opts['pureftpd_active'] = trim(shell_exec('systemctl is-active pure-ftpd 2>/dev/null') ?: '') === 'active'; + $opts['bind9_active'] = trim(shell_exec('systemctl is-active named 2>/dev/null || systemctl is-active bind9 2>/dev/null') ?: '') === 'active'; + $opts['powerdns_active'] = trim(shell_exec('systemctl is-active pdns 2>/dev/null') ?: '') === 'active'; + Response::success($opts); + })(), + + 'save-option' => (function() use ($db, $body) { + Auth::getInstance()->require('admin'); + $key = $body['key'] ?? ''; + $value = $body['value'] ?? ''; + $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"); + $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$key, $value]); + + // For server switches, run install/reload scripts + if (in_array($key, ['web_server','ftp_server','dns_server','mail_server'])) { + $script = "/opt/novacpx/bin/switch-{$key}.sh"; + if (is_executable($script)) { + shell_exec("sudo {$script} " . escapeshellarg($value) . " > /var/log/novacpx/switch-{$key}.log 2>&1 &"); + } + } + audit("settings.{$key}", $value); + Response::success(null, "Setting saved: {$key} = {$value}"); + })(), + default => Response::error("Unknown system action: $action", 404), }; diff --git a/panel/api/endpoints/whmcs.php b/panel/api/endpoints/whmcs.php new file mode 100644 index 0000000..0a1095f --- /dev/null +++ b/panel/api/endpoints/whmcs.php @@ -0,0 +1,125 @@ +fetchOne("SELECT value FROM settings WHERE `key`='whmcs_api_key'")['value'] ?? ''; +$enabled = (bool)($db->fetchOne("SELECT value FROM settings WHERE `key`='whmcs_enabled'")['value'] ?? '0'); + +if (!$enabled || !$storedKey) Response::error('WHMCS integration is disabled', 403); + +$receivedKey = $_SERVER['HTTP_X_WHMCS_KEY'] ?? $_GET['whmcs_key'] ?? ''; +if (!hash_equals($storedKey, $receivedKey)) Response::error('Invalid API key', 401); + +$body = json_decode(file_get_contents('php://input'), true) ?? []; + +match ($action) { + + 'create' => (function() use ($db, $body) { + $username = strtolower(preg_replace('/[^a-z0-9_]/', '', $body['username'] ?? '')); + $domain = strtolower(trim($body['domain'] ?? '')); + $email = trim($body['email'] ?? ''); + $pkgName = trim($body['package'] ?? 'Default'); + $password = $body['password'] ?? bin2hex(random_bytes(8)); + + if (!$username || !$domain) Response::error('username and domain required'); + + // Find or use default package + $pkg = $db->fetchOne("SELECT id FROM packages WHERE name = ? LIMIT 1", [$pkgName]) + ?? $db->fetchOne("SELECT id FROM packages WHERE is_default = 1 LIMIT 1") + ?? $db->fetchOne("SELECT id FROM packages LIMIT 1"); + if (!$pkg) Response::error('No packages configured'); + + // Create panel user + $existing = $db->fetchOne("SELECT id FROM users WHERE email = ?", [$email]); + if ($existing) { + $userId = (int)$existing['id']; + } else { + $db->execute( + "INSERT INTO users (username, email, password, role) VALUES (?,?,?,'user')", + [$username, $email ?: "{$username}@{$domain}", password_hash($password, PASSWORD_BCRYPT)] + ); + $userId = (int)$db->fetchOne("SELECT LAST_INSERT_ID() as id")['id']; + } + + $result = AccountManager::create([ + 'username' => $username, + 'domain' => $domain, + 'user_id' => $userId, + 'package_id' => $pkg['id'], + 'password' => $password, + ]); + + audit('whmcs.create', "account:{$username}"); + Response::success([ + 'account_id' => $result['account_id'] ?? null, + 'username' => $username, + 'domain' => $domain, + 'password' => $password, + ], 'Account created'); + })(), + + 'suspend' => (function() use ($db, $body) { + $username = $body['username'] ?? ''; + $reason = $body['reason'] ?? 'WHMCS suspend'; + $acct = $db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$username]); + if (!$acct) Response::error("Account not found: $username", 404); + AccountManager::suspend((int)$acct['id'], $reason); + audit('whmcs.suspend', "account:{$username}"); + Response::success(null, 'Account suspended'); + })(), + + 'unsuspend' => (function() use ($db, $body) { + $username = $body['username'] ?? ''; + $acct = $db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$username]); + if (!$acct) Response::error("Account not found: $username", 404); + AccountManager::unsuspend((int)$acct['id']); + audit('whmcs.unsuspend', "account:{$username}"); + Response::success(null, 'Account unsuspended'); + })(), + + 'terminate' => (function() use ($db, $body) { + $username = $body['username'] ?? ''; + $acct = $db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$username]); + if (!$acct) Response::error("Account not found: $username", 404); + AccountManager::terminate((int)$acct['id']); + audit('whmcs.terminate', "account:{$username}"); + Response::success(null, 'Account terminated'); + })(), + + 'changepackage' => (function() use ($db, $body) { + $username = $body['username'] ?? ''; + $pkgName = $body['package'] ?? ''; + $acct = $db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$username]); + if (!$acct) Response::error("Account not found: $username", 404); + $pkg = $db->fetchOne("SELECT id FROM packages WHERE name = ?", [$pkgName]); + if (!$pkg) Response::error("Package not found: $pkgName", 404); + $db->execute("UPDATE accounts SET package_id = ? WHERE id = ?", [(int)$pkg['id'], (int)$acct['id']]); + audit('whmcs.changepackage', "account:{$username} pkg:{$pkgName}"); + Response::success(null, 'Package changed'); + })(), + + 'info' => (function() use ($db, $body) { + $username = $body['username'] ?? $_GET['username'] ?? ''; + $acct = $db->fetchOne( + "SELECT a.*, p.name as package_name FROM accounts a LEFT JOIN packages p ON p.id=a.package_id WHERE a.username = ?", + [$username] + ); + if (!$acct) Response::error("Account not found: $username", 404); + Response::success([ + 'username' => $acct['username'], + 'domain' => $acct['domain'] ?? '', + 'status' => $acct['status'], + 'package' => $acct['package_name'] ?? '', + 'created' => $acct['created_at'] ?? '', + ]); + })(), + + default => Response::error("Unknown whmcs action: $action", 404), +}; diff --git a/panel/bin/collect-stats.php b/panel/bin/collect-stats.php new file mode 100644 index 0000000..1e3b394 --- /dev/null +++ b/panel/bin/collect-stats.php @@ -0,0 +1,65 @@ +#!/usr/bin/env php +execute( + "INSERT INTO server_stats (cpu_usage, ram_usage, disk_usage, load_avg, net_in_kb, net_out_kb) + VALUES (?,?,?,?,?,?)", + [$cpuPct, $ramPct, $diskPct, $load[0], $netIn, $netOut] +); + +// Prune rows older than 30 days +$db->execute("DELETE FROM server_stats WHERE recorded_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"); diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index 5773a96..8e79a46 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -150,6 +150,10 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f); Cloudflare + + + Server Options + Settings diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index f7d27c7..43815d7 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -98,6 +98,7 @@ updates, backups, cloudflare, + 'server-options': serverOptions, settings, }; @@ -182,29 +183,81 @@ // ── Server Status ────────────────────────────────────────────────────────── async function serverStatus() { - const res = await Nova.api('system', 'stats'); - const s = res?.data || {}; - return ` + const [liveRes, histRes] = await Promise.all([ + Nova.api('system', 'stats'), + Nova.api('stats', 'server'), + ]); + const s = liveRes?.data || {}; + const hist = histRes?.data?.history || []; + + const html = ` + +
+
CPU
${s.cpu?.pct??0}%
${Nova.progressBar(s.cpu?.pct||0)}
+
RAM
${s.ram?.pct??0}%
${Nova.progressBar(s.ram?.pct||0)}
+
Disk
${s.disk?.pct??0}%
${Nova.progressBar(s.disk?.pct||0)}
+
Load Avg
${(s.cpu?.load||[0]).map(v=>v.toFixed(2)).join(' / ')}
Uptime: ${s.uptime||'—'}
+
-
Real-Time Server Status - -
+
24-Hour History${hist.length} samples
-
-

CPU

${s.cpu?.pct}%

${Nova.progressBar(s.cpu?.pct||0)}
-

RAM

${s.ram?.pct}%

${Nova.progressBar(s.ram?.pct||0)}
-

Disk

${s.disk?.pct}%

${Nova.progressBar(s.disk?.pct||0)}
-
-
-

Load Average

-

${(s.cpu?.load||[]).join(' / ')}

-
-
-

Uptime

-

${s.uptime}

-
+ ${hist.length === 0 + ? '

No history yet — stats are collected every 5 minutes.
Check that the collector cron is running: */5 * * * * root /usr/bin/php /opt/novacpx/bin/collect-stats.php

' + : ''}
`; + + // Can't return html and async render chart — use a trick: render then init chart + setTimeout(() => { + const canvas = document.getElementById('stats-chart'); + if (!canvas || !hist.length) return; + if (!window.Chart) { + const s = document.createElement('script'); + s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'; + s.onload = () => initStatsChart(canvas, hist); + document.head.appendChild(s); + } else { + initStatsChart(canvas, hist); + } + }, 100); + + return html; + } + + function initStatsChart(canvas, hist) { + const labels = hist.map(r => { + const d = new Date(r.recorded_at); + return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0'); + }); + const step = Math.max(1, Math.floor(labels.length / 24)); + const sparse = labels.map((l,i) => i % step === 0 ? l : ''); + + new Chart(canvas, { + type: 'line', + data: { + labels: sparse, + datasets: [ + { label: 'CPU %', data: hist.map(r=>parseFloat(r.cpu_usage||0)), borderColor:'#6366f1', backgroundColor:'rgba(99,102,241,.1)', tension:.3, pointRadius:0, fill:true }, + { label: 'RAM %', data: hist.map(r=>parseFloat(r.ram_usage||0)), borderColor:'#0ea5e9', backgroundColor:'rgba(14,165,233,.1)', tension:.3, pointRadius:0, fill:true }, + { label: 'Disk %', data: hist.map(r=>parseFloat(r.disk_usage||0)), borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,.08)', tension:.3, pointRadius:0, fill:true }, + ], + }, + options: { + responsive: true, + animation: false, + interaction: { mode:'index', intersect:false }, + scales: { + x: { grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', maxRotation:0 } }, + y: { min:0, max:100, grid:{ color:'rgba(255,255,255,.05)' }, ticks:{ color:'#8b92a5', callback: v=>v+'%' } }, + }, + plugins: { + legend: { labels:{ color:'#e2e4f0', font:{ size:12 } } }, + tooltip: { callbacks:{ label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%` } }, + }, + }, + }); } // ── Updates ──────────────────────────────────────────────────────────────── @@ -2527,3 +2580,182 @@ window.dockerQuotaModal = (userId, username) => { Nova.toast(r?.success ? 'Quota saved' : (r?.message||'Failed'), r?.success?'success':'error'); }; }; + +// ── #22a-e Server Options ────────────────────────────────────────────────── +async function serverOptions() { + const r = await Nova.api('system', 'server-options'); + const opts = r?.data || {}; + + return ` + + +
+ + +
+
Web Server${Nova.badge(opts.web_server||'apache','green')}
+
+

Current web server for hosting accounts. Changing requires migration of all vhosts.

+
+ + +
+ +
+
+ + +
+
Mail Server${Nova.badge(opts.mail_server||'postfix-dovecot','green')}
+
+

Mail stack for all hosted domains.

+
+ + +
+ +
+
+ + +
+
FTP Server${Nova.badge(opts.ftp_server||'proftpd','green')}
+
+

FTP server for hosting account file transfers.

+
+ + +
+ +
+
+ + +
+
DNS Server${Nova.badge(opts.dns_server||'bind9','green')}
+
+

DNS server for authoritative name service.

+
+ + +
+ +
+
+ +
+ + +
+
+ WHMCS Billing Bridge + ${opts.whmcs_enabled==='1' ? Nova.badge('Enabled','green') : Nova.badge('Disabled','red')} +
+
+

+ Enable the WHMCS provisioning API so WHMCS can create, suspend, unsuspend, and terminate accounts automatically. + Use the API URL below in your WHMCS server module configuration. +

+
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ Nameserver Health + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
`; +} + +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 () => { + 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); + }, true); +}; + +window.soSaveWhmcs = async () => { + const key = document.getElementById('so-whmcs-key')?.value?.trim(); + const enabled = document.getElementById('so-whmcs-enabled')?.value; + const r1 = await Nova.api('system', 'save-option', { method:'POST', body:{ key:'whmcs_api_key', value:key } }); + const r2 = await Nova.api('system', 'save-option', { method:'POST', body:{ key:'whmcs_enabled', value:enabled } }); + Nova.toast((r1?.success && r2?.success) ? 'WHMCS settings saved' : 'Save failed', (r1?.success && r2?.success)?'success':'error'); +}; + +window.soSaveNS = async () => { + const ns1 = document.getElementById('so-ns1')?.value?.trim(); + const ns2 = document.getElementById('so-ns2')?.value?.trim(); + await Nova.api('system', 'save-option', { method:'POST', body:{ key:'ns1_hostname', value:ns1 } }); + await Nova.api('system', 'save-option', { method:'POST', body:{ key:'ns2_hostname', value:ns2 } }); + Nova.toast('Nameservers saved', 'success'); +}; + +window.soCheckNS = async () => { + const tc = document.getElementById('so-ns-results'); + if (!tc) return; + tc.innerHTML = '
Checking NS records…
'; + const r = await Nova.api('dns', 'ns-health'); + const results = r?.data?.results || []; + if (!results.length) { tc.innerHTML = '

No zones to check, or DNS manager not configured.

'; return; } + tc.innerHTML = `
+${results.map(z=>` + + + + +`).join('')} +
DomainNS1NS2Status
${Nova.escHtml(z.domain)}${Nova.escHtml(z.ns1||'—')}${Nova.escHtml(z.ns2||'—')}${z.ok ? Nova.badge('OK','green') : Nova.badge('Mismatch','red')}
`; +};