mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
3a1746b0c0
1. admin.js: dashboard setTimeout was after return (dead code) — restructured to assign template to const html, run setTimeout, then return html 2. DockerManager.php createStack: replaced SELECT LAST_INSERT_ID() with db->insert() which already returns lastInsertId correctly for SQLite 3. DockerManager.php setQuota: replaced ON DUPLICATE KEY UPDATE / VALUES() MySQL syntax with SQLite-compatible ON CONFLICT(user_id) DO UPDATE SET excluded.col syntax 4. post-restore.sh: PHP helper file now written ONCE at start of step 4 before any call to it (was written AFTER first call, causing silent failure) 5. post-restore.sh: git pull exit code now captured before pipeline (the while-read loop always exited 0, masking pull failures) 6. uninstall.sh: tar backup now aborts on failure (previously 2>/dev/null swallowed errors and rm -rf destroyed source unconditionally); also rm -f → rm -rf for .service.d drop-in directory
4785 lines
252 KiB
JavaScript
4785 lines
252 KiB
JavaScript
/**
|
||
* NovaCPX Admin Panel — page controllers
|
||
*/
|
||
(async () => {
|
||
// ── Auth guard ─────────────────────────────────────────────────────────────
|
||
// Inline login handler on port 8882
|
||
let _loginCredentials = null;
|
||
const loginForm = document.getElementById('login-form');
|
||
if (loginForm) {
|
||
loginForm.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('l-btn');
|
||
const err = document.getElementById('login-err');
|
||
btn.disabled = true; err.style.display = 'none';
|
||
|
||
// Step 2: TOTP code entry
|
||
const totpInput = document.getElementById('l-totp');
|
||
if (totpInput && _loginCredentials) {
|
||
btn.textContent = 'Verifying…';
|
||
const res = await Nova.api('auth', 'login', {
|
||
method: 'POST',
|
||
body: { ..._loginCredentials, totp_code: totpInput.value.trim() }
|
||
});
|
||
if (res?.success && res.data?.user?.role === 'admin') {
|
||
location.reload();
|
||
} else {
|
||
err.textContent = res?.message || 'Invalid 2FA code';
|
||
err.style.display = '';
|
||
btn.disabled = false; btn.textContent = 'Verify';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Step 1: username + password
|
||
btn.textContent = 'Signing in…';
|
||
const creds = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
|
||
const res = await Nova.api('auth', 'login', { method: 'POST', body: creds });
|
||
if (res?.success && res.data?.user?.role === 'admin') {
|
||
location.reload();
|
||
} else if (res?.totp_required) {
|
||
// Show TOTP step
|
||
_loginCredentials = creds;
|
||
document.getElementById('l-user').closest('.form-group').style.display = 'none';
|
||
document.getElementById('l-pass').closest('.form-group').style.display = 'none';
|
||
const totpGroup = document.createElement('div');
|
||
totpGroup.className = 'form-group';
|
||
totpGroup.innerHTML = '<label>2FA Code</label><input id="l-totp" type="text" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="6-digit code" autofocus>';
|
||
loginForm.insertBefore(totpGroup, btn.parentNode || btn);
|
||
btn.textContent = 'Verify'; btn.disabled = false;
|
||
} else {
|
||
err.textContent = res?.message || 'Invalid credentials or insufficient role';
|
||
err.style.display = '';
|
||
btn.disabled = false; btn.textContent = 'Sign In to Admin';
|
||
}
|
||
});
|
||
}
|
||
|
||
const me = await Nova.api('auth', 'me');
|
||
if (!me?.success || me.data.role !== 'admin') {
|
||
// Already showing the login form in #auth-check
|
||
return;
|
||
}
|
||
document.getElementById('auth-check').style.display = 'none';
|
||
document.getElementById('app').style.display = '';
|
||
document.getElementById('user-name').textContent = me.data.username;
|
||
document.getElementById('user-avatar').textContent = me.data.username[0].toUpperCase();
|
||
|
||
// ── Logout ─────────────────────────────────────────────────────────────────
|
||
document.getElementById('logout-btn').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
await Nova.api('auth', 'logout', { method: 'POST' });
|
||
location.href = '/';
|
||
});
|
||
|
||
// ── Page definitions ───────────────────────────────────────────────────────
|
||
const pages = {
|
||
dashboard,
|
||
'server-status': dashboard,
|
||
accounts,
|
||
resellers,
|
||
packages,
|
||
'create-account': createAccount,
|
||
'dns-zones': dnsZones,
|
||
nameservers,
|
||
'web-server': webServer,
|
||
'php-manager': phpManager,
|
||
'mysql-manager': mysqlManager,
|
||
'mail-server': mailServer,
|
||
'ftp-server': ftpServer,
|
||
'nginx-proxy': nginxProxy,
|
||
sessions,
|
||
wordpress,
|
||
docker,
|
||
'ssl-manager': sslManager,
|
||
firewall,
|
||
fail2ban,
|
||
'audit-log': auditLog,
|
||
twofa,
|
||
updates,
|
||
backups,
|
||
cloudflare,
|
||
'server-options': serverOptions,
|
||
'subdomains': window.adminSubdomains,
|
||
'parked-domains': window.adminParked,
|
||
notifications,
|
||
settings,
|
||
};
|
||
|
||
window._novaPages = pages;
|
||
Nova.initNav(pages);
|
||
await Nova.loadPage('dashboard', pages);
|
||
checkUpdates();
|
||
|
||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||
async function dashboard() {
|
||
const [stats, version, histRes] = await Promise.all([
|
||
Nova.api('system', 'stats'),
|
||
Nova.api('system', 'version'),
|
||
Nova.api('stats', 'server'),
|
||
]);
|
||
const s = stats?.data || {};
|
||
const v = version?.data || {};
|
||
const hist = histRes?.data?.history || [];
|
||
|
||
document.getElementById('server-ip').textContent = '';
|
||
|
||
const html = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-label">CPU Usage</div>
|
||
<div class="stat-value ${s.cpu?.pct > 80 ? 'stat-red' : 'stat-green'}">${s.cpu?.pct ?? 0}%</div>
|
||
<div class="stat-sub">Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}</div>
|
||
<div class="mt-1">${Nova.progressBar(s.cpu?.pct || 0)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Memory</div>
|
||
<div class="stat-value ${s.ram?.pct > 80 ? 'stat-red' : 'stat-blue'}">${s.ram?.pct ?? 0}%</div>
|
||
<div class="stat-sub">${Nova.bytes((s.ram?.used_kb||0)*1024)} / ${Nova.bytes((s.ram?.total_kb||0)*1024)}</div>
|
||
<div class="mt-1">${Nova.progressBar(s.ram?.pct || 0)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Disk</div>
|
||
<div class="stat-value ${s.disk?.pct > 85 ? 'stat-red' : 'stat-yellow'}">${s.disk?.pct ?? 0}%</div>
|
||
<div class="stat-sub">${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used</div>
|
||
<div class="mt-1">${Nova.progressBar(s.disk?.pct || 0)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Uptime</div>
|
||
<div class="stat-value stat-green" style="font-size:1rem;padding-top:.4rem">${s.uptime || '—'}</div>
|
||
<div class="stat-sub">PHP ${v.php_version || '—'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2 gap-2">
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">Services</span></div>
|
||
<div class="card-body">
|
||
<table><tbody>
|
||
${Object.entries(s.services || {}).map(([svc, status]) => `
|
||
<tr>
|
||
<td><span data-svc-dot="${svc}">${Nova.serviceDot(status)}</span> ${svc}</td>
|
||
<td><span data-svc-status="${svc}">${Nova.badge(status, status === 'active' ? 'green' : 'red')}</span></td>
|
||
<td class="text-right">
|
||
<button class="btn btn-ghost btn-sm" onclick="adminServiceAction('${svc}','restart')">Restart</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="adminServiceAction('${svc}','stop')">Stop</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">NovaCPX Version</span></div>
|
||
<div class="card-body">
|
||
<table><tbody>
|
||
<tr><td class="text-muted">Installed</td><td><strong>${v.installed_version || '—'}</strong></td></tr>
|
||
<tr><td class="text-muted">Branch</td><td><code>${v.git_branch || 'main'}</code></td></tr>
|
||
<tr><td class="text-muted">Commit</td><td><code>${v.git_commit || '—'}</code>${v.git_dirty ? ' <span class="badge badge-yellow">dirty</span>' : ''}</td></tr>
|
||
<tr><td class="text-muted">PHP</td><td>${v.php_version || '—'}</td></tr>
|
||
<tr><td class="text-muted">OS</td><td>${v.os || '—'}</td></tr>
|
||
</tbody></table>
|
||
<div class="mt-2">
|
||
<button class="btn btn-primary btn-sm" onclick="adminPage('updates')">Check for Updates</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:1.5rem">
|
||
<div class="card-header">
|
||
<span class="card-title">24-Hour History</span>
|
||
<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">${hist.length} samples</span>
|
||
<button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="adminPage('dashboard')">↻</button>
|
||
</div>
|
||
<div class="card-body">
|
||
${hist.length === 0
|
||
? '<p class="text-muted" style="text-align:center;padding:2rem">No history yet — collected every 5 minutes.</p>'
|
||
: '<canvas id="dash-hist-chart" height="70"></canvas>'}
|
||
</div>
|
||
</div>`;
|
||
// setTimeout BEFORE return so it actually executes (after return is dead code)
|
||
setTimeout(()=>{const c=document.getElementById('dash-hist-chart');if(!c||!hist.length)return;if(window.Chart){initStatsChart(c,hist);}else{const s2=document.createElement('script');s2.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';s2.onload=()=>initStatsChart(c,hist);document.head.appendChild(s2);}},150);
|
||
return html;
|
||
}
|
||
|
||
// ── Server Status ──────────────────────────────────────────────────────────
|
||
|
||
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 ────────────────────────────────────────────────────────────────
|
||
async function updates(force = false) {
|
||
const qp = force ? { force: 1 } : {};
|
||
const [ver, ncpxCheck, osCheck] = await Promise.all([
|
||
Nova.api('system', 'version'),
|
||
Nova.api('system', 'check-novacpx-update', { params: qp }),
|
||
Nova.api('system', 'check-os-update', { params: qp }),
|
||
]);
|
||
const v = ver?.data || {};
|
||
const ncpx = ncpxCheck?.data || {};
|
||
const os = osCheck?.data || {};
|
||
const ncpxCount = ncpx.updates_available || 0;
|
||
const osCount = os.upgradable || 0;
|
||
|
||
const html = `
|
||
<div class="page-header mb-3">
|
||
<h2 class="page-title">Updates</h2>
|
||
<div style="display:flex;align-items:center;gap:1rem;margin-left:auto">
|
||
${(ncpx.cached || os.cached) ? `<span class="text-muted text-sm">Cached · last checked ${Nova.relTime(ncpx.cached_at || os.cached_at)}</span>` : ''}
|
||
<button class="btn btn-ghost btn-sm" onclick="forceRefreshUpdates()">↻ Refresh now</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NovaCPX Panel Updates -->
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">
|
||
<svg class="icon-sm mr-1"><use href="/assets/img/nova-icons.svg#ni-updates"/></svg>
|
||
NovaCPX Panel
|
||
</span>
|
||
${ncpxCount > 0 ? Nova.badge(ncpxCount + ' commit' + (ncpxCount > 1 ? 's' : '') + ' available', 'yellow') : Nova.badge('Up to date', 'green')}
|
||
<button class="btn btn-ghost btn-sm ml-auto" onclick="adminPage('updates')">
|
||
<svg class="icon-xs"><use href="/assets/img/nova-icons.svg#ni-search"/></svg> Refresh
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="grid-4 mb-3">
|
||
<div><p class="text-muted text-sm">Installed</p><p class="font-bold">${v.installed_version || '—'}</p></div>
|
||
<div><p class="text-muted text-sm">Latest (${ncpx.channel || 'stable'})</p><p class="font-bold">${ncpx.remote_version || (ncpxCount > 0 ? 'available' : v.installed_version || '—')}</p></div>
|
||
<div><p class="text-muted text-sm">Channel</p>${Nova.badge(ncpx.channel || 'stable', ncpx.channel === 'beta' ? 'yellow' : 'green')}</div>
|
||
<div><p class="text-muted text-sm">PHP</p><code>${v.php_version || '—'}</code></div>
|
||
</div>
|
||
|
||
${ncpxCount > 0 ? `
|
||
<div class="card mb-2" style="background:var(--bg3)">
|
||
<div class="card-header"><span class="card-title">Pending Commits</span></div>
|
||
<div class="card-body terminal" style="max-height:140px;overflow-y:auto">
|
||
${ncpx.commits?.map(c => `<div>${Nova.escHtml(c)}</div>`).join('') || 'None'}
|
||
</div>
|
||
</div>
|
||
<p class="text-muted text-sm mb-2">PHP syntax is validated before deploy. If the panel goes down after update, it will automatically restore from backup.</p>
|
||
<button class="btn btn-primary" id="ncpx-update-btn" onclick="applyNovaCPXUpdate()">
|
||
<svg class="icon-xs mr-1"><use href="/assets/img/nova-icons.svg#ni-updates"/></svg>
|
||
Update NovaCPX
|
||
</button>
|
||
` : `<p class="text-muted">NovaCPX is up to date.</p>`}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Installed Services -->
|
||
<div class="card mb-3" id="svc-versions-card">
|
||
<div class="card-header">
|
||
<span class="card-title">
|
||
<svg class="icon-sm mr-1"><use href="/assets/img/nova-icons.svg#ni-server"/></svg>
|
||
Installed Services
|
||
</span>
|
||
<button class="btn btn-ghost btn-sm ml-auto" onclick="loadServiceVersions()">↻ Refresh</button>
|
||
</div>
|
||
<div class="card-body" id="svc-versions-body">
|
||
<div class="loading">Loading service inventory…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OS Updates -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">
|
||
<svg class="icon-sm mr-1"><use href="/assets/img/nova-icons.svg#ni-server"/></svg>
|
||
Operating System Packages
|
||
</span>
|
||
${os.security_updates > 0 ? Nova.badge(os.security_updates + ' security', 'red') : ''}
|
||
${osCount > 0 ? Nova.badge(osCount + ' upgradable', 'yellow') : Nova.badge('All current', 'green')}
|
||
</div>
|
||
<div class="card-body">
|
||
${osCount > 0 ? `
|
||
<div class="table-wrap mb-2" style="max-height:200px;overflow-y:auto">
|
||
<table>
|
||
<thead><tr><th>Package</th><th>From</th><th>To</th></tr></thead>
|
||
<tbody>
|
||
${os.packages?.map(p => `<tr>
|
||
<td><code>${Nova.escHtml(p.name)}</code></td>
|
||
<td class="text-muted text-sm">${Nova.escHtml(p.from || '(new)')}</td>
|
||
<td class="text-sm">${Nova.escHtml(p.to)}</td>
|
||
</tr>`).join('') || ''}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p class="text-muted text-sm mb-2">Services are automatically restarted if an upgrade stops them. The NovaCPX web root is backed up before upgrade and restored if panel ports go down.</p>
|
||
<button class="btn btn-warning" id="os-update-btn" onclick="applyOSUpdate()">
|
||
<svg class="icon-xs mr-1"><use href="/assets/img/nova-icons.svg#ni-server"/></svg>
|
||
Apply OS Upgrade
|
||
</button>
|
||
` : `<p class="text-muted">All OS packages are current.</p>`}
|
||
</div>
|
||
</div>`;
|
||
|
||
setTimeout(loadServiceVersions, 80);
|
||
return html;
|
||
}
|
||
|
||
window.forceRefreshUpdates = () => {
|
||
const content = document.getElementById('page-content');
|
||
if (!content) return;
|
||
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Checking for updates…</div>';
|
||
updates(true).then(html => { if (html) content.innerHTML = html; });
|
||
};
|
||
|
||
window.loadServiceVersions = async () => {
|
||
const body = document.getElementById('svc-versions-body');
|
||
if (!body) return;
|
||
body.innerHTML = '<div class="loading">Scanning installed services…</div>';
|
||
const r = await Nova.api('system', 'service-versions');
|
||
const svcs = r?.data?.services || [];
|
||
if (!svcs.length) { body.innerHTML = '<p class="text-muted">No tracked services found.</p>'; return; }
|
||
const statusDot = s => s === 'active'
|
||
? '<span style="color:var(--green);font-size:.75rem">● running</span>'
|
||
: s === null ? '<span style="color:var(--text-muted);font-size:.75rem">—</span>'
|
||
: '<span style="color:var(--red);font-size:.75rem">● stopped</span>';
|
||
body.innerHTML = `<div style="overflow-x:auto"><table class="table">
|
||
<thead><tr><th>Service</th><th>Description</th><th>Installed</th><th>Latest</th><th>Status</th><th>State</th></tr></thead>
|
||
<tbody>${svcs.map(s => `<tr>
|
||
<td><strong>${Nova.escHtml(s.label)}</strong><br><code style="font-size:.7rem">${Nova.escHtml(s.pkg)}</code></td>
|
||
<td style="font-size:.82rem;color:var(--text-muted);max-width:280px">${Nova.escHtml(s.desc)}</td>
|
||
<td><code style="font-size:.8rem">${Nova.escHtml(s.installed)}</code></td>
|
||
<td><code style="font-size:.8rem;color:${s.up_to_date === false ? 'var(--yellow)' : 'var(--text-muted)'}">${Nova.escHtml(s.latest)}</code></td>
|
||
<td>${s.up_to_date === true ? Nova.badge('current','green') : s.up_to_date === false ? Nova.badge('update available','yellow') : '<span style="color:var(--text-muted);font-size:.8rem">—</span>'}</td>
|
||
<td>${statusDot(s.status)}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>`;
|
||
};
|
||
|
||
// ── Audit Log ──────────────────────────────────────────────────────────────
|
||
async function auditLog(opts = {}) {
|
||
const { page = 1, user = '', action = '', date_from = '', date_to = '' } = opts;
|
||
const params = { page, per_page: 50 };
|
||
if (user) params.user = user;
|
||
if (action) params.action = action;
|
||
if (date_from) params.date_from = date_from;
|
||
if (date_to) params.date_to = date_to;
|
||
|
||
const content = document.getElementById('page-content');
|
||
const filterBar = `
|
||
<div class="card" style="margin-bottom:1rem">
|
||
<div class="card-body" style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
|
||
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||
<label style="font-size:.75rem;margin-bottom:.25rem">User</label>
|
||
<input id="al-user" class="form-control form-control-sm" value="${Nova.escHtml(user)}" placeholder="username…">
|
||
</div>
|
||
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||
<label style="font-size:.75rem;margin-bottom:.25rem">Action</label>
|
||
<input id="al-action" class="form-control form-control-sm" value="${Nova.escHtml(action)}" placeholder="e.g. account.create">
|
||
</div>
|
||
<div class="form-group" style="margin:0;min-width:130px">
|
||
<label style="font-size:.75rem;margin-bottom:.25rem">From</label>
|
||
<input id="al-from" type="date" class="form-control form-control-sm" value="${Nova.escHtml(date_from)}">
|
||
</div>
|
||
<div class="form-group" style="margin:0;min-width:130px">
|
||
<label style="font-size:.75rem;margin-bottom:.25rem">To</label>
|
||
<input id="al-to" type="date" class="form-control form-control-sm" value="${Nova.escHtml(date_to)}">
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="alApplyFilter()">Filter</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="auditLog()">Reset</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
if (content) content.innerHTML = filterBar + '<div class="page-loader">Loading…</div>';
|
||
|
||
const res = await Nova.api('system', 'audit-log', { params });
|
||
const rows = res?.data || [];
|
||
const meta = res?.meta || {};
|
||
const total = meta.total || rows.length;
|
||
const pages = meta.pages || 1;
|
||
|
||
const tableHtml = rows.length ? `
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Resource</th><th>IP</th><th></th></tr></thead>
|
||
<tbody>
|
||
${rows.map((r, i) => `
|
||
<tr style="cursor:pointer" onclick="alToggleDetail(${i})">
|
||
<td class="text-muted text-sm" style="white-space:nowrap">${Nova.relTime(r.created_at)}</td>
|
||
<td>${Nova.escHtml(r.username || '—')}</td>
|
||
<td><code style="font-size:.8rem">${Nova.escHtml(r.action)}</code></td>
|
||
<td class="text-muted text-sm">${Nova.escHtml(r.resource || '—')}</td>
|
||
<td class="text-muted text-sm" style="white-space:nowrap">${Nova.escHtml(r.ip_address || '—')}</td>
|
||
<td style="width:20px;color:var(--text-muted)">▾</td>
|
||
</tr>
|
||
<tr id="al-detail-${i}" style="display:none">
|
||
<td colspan="6" style="background:var(--bg3);padding:.75rem 1rem">
|
||
<pre style="margin:0;font-size:.78rem;white-space:pre-wrap;word-break:break-all">${
|
||
r.detail ? JSON.stringify(JSON.parse(r.detail || '{}'), null, 2) : '(no detail)'
|
||
}</pre>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : '<div class="empty-state"><p>No audit entries match the current filters.</p></div>';
|
||
|
||
const paginationHtml = pages > 1 ? `
|
||
<div style="display:flex;gap:.5rem;justify-content:center;padding:1rem;flex-wrap:wrap">
|
||
${Array.from({length: pages}, (_, i) => i + 1).map(p => `
|
||
<button class="btn btn-sm ${p === page ? 'btn-primary' : 'btn-ghost'}" onclick="alGoPage(${p})">${p}</button>
|
||
`).join('')}
|
||
</div>` : '';
|
||
|
||
const tableCard = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Audit Log</span>
|
||
<span class="text-muted text-sm">${total} entr${total !== 1 ? 'ies' : 'y'}</span>
|
||
</div>
|
||
${tableHtml}
|
||
${paginationHtml}
|
||
</div>`;
|
||
|
||
if (content) content.innerHTML = filterBar + tableCard;
|
||
else return filterBar + tableCard;
|
||
|
||
window._alOpts = opts;
|
||
}
|
||
|
||
window.alToggleDetail = (i) => {
|
||
const row = document.getElementById('al-detail-' + i);
|
||
if (row) row.style.display = row.style.display === 'none' ? '' : 'none';
|
||
};
|
||
window.alApplyFilter = () => {
|
||
auditLog({
|
||
page: 1,
|
||
user: document.getElementById('al-user')?.value || '',
|
||
action: document.getElementById('al-action')?.value || '',
|
||
date_from: document.getElementById('al-from')?.value || '',
|
||
date_to: document.getElementById('al-to')?.value || '',
|
||
});
|
||
};
|
||
window.alGoPage = (p) => auditLog({ ...(window._alOpts || {}), page: p });
|
||
|
||
// ── PHP Manager ────────────────────────────────────────────────────────────
|
||
async function phpManager() {
|
||
const res = await Nova.api('php', 'versions');
|
||
const data = res?.data || {};
|
||
const vers = data.versions || [];
|
||
const panelPhp = data.panel_php || '—';
|
||
|
||
return `
|
||
<div class="page-header">
|
||
<h2 class="page-title">PHP Manager</h2>
|
||
<button class="btn btn-ghost btn-sm" onclick="adminPage('php-manager')">↻ Refresh</button>
|
||
</div>
|
||
|
||
<div class="card mb-2">
|
||
<div class="card-header"><span class="card-title">Panel PHP</span></div>
|
||
<div class="card-body">
|
||
<p class="text-muted text-sm">NovaCPX itself runs on <strong>PHP ${panelPhp}</strong> (always the highest installed version, updated automatically when a new version is installed).</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-2">
|
||
<div class="card-header"><span class="card-title">Installed Versions</span></div>
|
||
<div class="card-body">
|
||
<div class="grid-4">
|
||
${vers.map(v => `
|
||
<div class="stat-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
|
||
<strong>PHP ${v.version}</strong>
|
||
<span data-svc-status="php${v.version}-fpm">${v.installed ? Nova.badge(v.fpm_active ? 'active' : 'stopped', v.fpm_active ? 'green' : 'yellow') : Nova.badge('not installed','muted')}</span>
|
||
</div>
|
||
${v.is_default ? `<div class="text-xs text-muted mb-1">Panel default</div>` : ''}
|
||
<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.5rem">
|
||
${v.installed ? `
|
||
<button class="btn btn-xs" onclick="phpExtModal('${v.version}')">Extensions</button>
|
||
<button class="btn btn-xs" onclick="phpFpmAction('${v.version}','restart')">Restart FPM</button>
|
||
${!v.is_default ? `<button class="btn btn-xs btn-danger" onclick="phpRemoveVersion('${v.version}')">Remove</button>` : ''}
|
||
` : `
|
||
<button class="btn btn-xs btn-primary" onclick="phpInstallVersion('${v.version}')">Install</button>
|
||
`}
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="php-ext-panel" style="display:none"></div>`;
|
||
}
|
||
|
||
window.phpInstallVersion = (ver) => {
|
||
Nova.confirm(`Install PHP ${ver}? This will run apt-get and may take a minute.`, async () => {
|
||
Nova.loading(`Installing PHP ${ver}…`);
|
||
const r = await Nova.api('php', 'install-version', { method: 'POST', body: { version: ver } });
|
||
Nova.loadingDone();
|
||
if (r?.success) { Nova.toast(`PHP ${ver} installed`, 'success'); adminPage('php-manager'); }
|
||
else Nova.toast(r?.message || 'Install failed', 'error');
|
||
});
|
||
};
|
||
|
||
window.phpRemoveVersion = (ver) => {
|
||
Nova.confirm(`Remove PHP ${ver}? All FPM pools for this version will stop.`, async () => {
|
||
Nova.loading(`Removing PHP ${ver}…`);
|
||
const r = await Nova.api('php', 'remove-version', { method: 'POST', body: { version: ver } });
|
||
Nova.loadingDone();
|
||
if (r?.success) { Nova.toast(`PHP ${ver} removed`, 'success'); adminPage('php-manager'); }
|
||
else Nova.toast(r?.message || 'Remove failed', 'error');
|
||
}, true);
|
||
};
|
||
|
||
window.phpFpmAction = async (ver, cmd) => {
|
||
Nova.loading(`${cmd} php${ver}-fpm…`);
|
||
const r = await Nova.api('php', 'fpm-action', { method: 'POST', body: { version: ver, command: cmd } });
|
||
Nova.loadingDone();
|
||
if (r?.success) { Nova.toast(r.message, 'success'); refreshSvcStatus(`php${ver}-fpm`); }
|
||
else Nova.toast(r?.message || 'Action failed', 'error');
|
||
};
|
||
|
||
window.phpExtModal = async (ver) => {
|
||
const panel = document.getElementById('php-ext-panel');
|
||
if (!panel) return;
|
||
panel.style.display = '';
|
||
panel.innerHTML = `<div class="card"><div class="card-body"><p class="text-muted">Loading extensions for PHP ${ver}…</p></div></div>`;
|
||
panel.scrollIntoView({ behavior: 'smooth' });
|
||
|
||
const r = await Nova.api('php', 'version-extensions', { params: { version: ver } });
|
||
if (!r?.success) { panel.innerHTML = `<div class="card"><div class="card-body"><p class="text-muted">${r?.message || 'Failed to load'}</p></div></div>`; return; }
|
||
|
||
const installed = r.data.installed || [];
|
||
const available = r.data.available || [];
|
||
const notInstalled = available.filter(pkg => {
|
||
const ext = pkg.replace(/^php[\d.]+-/, '');
|
||
return !installed.some(i => i.toLowerCase() === ext.toLowerCase() || i.toLowerCase().replace('_','-') === ext.toLowerCase());
|
||
});
|
||
|
||
panel.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">PHP ${ver} Extensions</span>
|
||
<div style="display:flex;gap:.5rem;align-items:center">
|
||
<input id="php-ext-search" class="form-control" style="width:160px" placeholder="Filter…" oninput="phpExtFilter(this.value)">
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('php-ext-panel').style.display='none'">✕ Close</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="margin-bottom:1rem">
|
||
<strong>Add extension</strong>
|
||
<div style="display:flex;gap:.5rem;margin-top:.5rem;flex-wrap:wrap">
|
||
<select id="php-ext-add-sel" class="form-control" style="width:220px">
|
||
<option value="">— choose from common list —</option>
|
||
${notInstalled.map(p => `<option value="${p.replace(/^php[\d.]+-/,'')}">${p.replace(/^php[\d.]+-/,'')}</option>`).join('')}
|
||
</select>
|
||
<span class="text-muted" style="align-self:center">or</span>
|
||
<input id="php-ext-add-custom" class="form-control" style="width:140px" placeholder="custom name">
|
||
<button class="btn btn-primary btn-sm" onclick="phpExtInstall('${ver}')">Install</button>
|
||
</div>
|
||
</div>
|
||
<div id="php-ext-list">
|
||
<table class="table">
|
||
<thead><tr><th>Extension</th><th style="text-align:right">Action</th></tr></thead>
|
||
<tbody>
|
||
${installed.map(e => `
|
||
<tr class="php-ext-row" data-ext="${e.toLowerCase()}">
|
||
<td><code>${e}</code></td>
|
||
<td style="text-align:right">
|
||
<button class="btn btn-xs btn-danger" onclick="phpExtRemove('${ver}','${e}')">Remove</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
};
|
||
|
||
window.phpExtFilter = (q) => {
|
||
document.querySelectorAll('.php-ext-row').forEach(row => {
|
||
row.style.display = row.dataset.ext.includes(q.toLowerCase()) ? '' : 'none';
|
||
});
|
||
};
|
||
|
||
const _phpExtStream = (ver, ext, action) => {
|
||
const termId = 'phpext-term-' + Date.now();
|
||
const verb = action === 'install-extension' ? 'Installing' : 'Removing';
|
||
Nova.modal(`${verb} ${ext} (PHP ${ver})`, `
|
||
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
|
||
padding:1rem;border-radius:6px;height:240px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||
<span style="color:#7ec8e3">Starting…</span>\n
|
||
</div>`,
|
||
`<button class="btn btn-ghost" id="phpext-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
|
||
const term = document.getElementById(termId);
|
||
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
|
||
fetch(`/api/php/${action}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ version: ver, extension: ext }),
|
||
credentials: 'same-origin',
|
||
}).then(resp => {
|
||
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
const read = () => reader.read().then(({ done, value }) => {
|
||
if (done) { append('\n[done]'); return; }
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const obj = JSON.parse(m[1]);
|
||
if (obj.line) { append(obj.line); }
|
||
else if (obj.done) {
|
||
const btn = document.getElementById('phpext-close');
|
||
if (btn) {
|
||
btn.textContent = obj.success ? 'Done ✓' : 'Close';
|
||
btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost';
|
||
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); phpExtModal(ver); };
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
read();
|
||
}).catch(err => append(`\n[error: ${err.message}]`));
|
||
read();
|
||
}).catch(err => append(`\nFetch error: ${err.message}`));
|
||
};
|
||
|
||
window.phpExtInstall = (ver) => {
|
||
const sel = document.getElementById('php-ext-add-sel')?.value;
|
||
const custom = document.getElementById('php-ext-add-custom')?.value?.trim();
|
||
const ext = custom || sel;
|
||
if (!ext) { Nova.toast('Choose or type an extension name', 'error'); return; }
|
||
_phpExtStream(ver, ext, 'install-extension');
|
||
};
|
||
|
||
window.phpExtRemove = (ver, ext) => {
|
||
Nova.confirm(`Remove extension ${ext} from PHP ${ver}?`, () => {
|
||
_phpExtStream(ver, ext, 'remove-extension');
|
||
}, true);
|
||
};
|
||
|
||
// ── Notifications (#25) ───────────────────────────────────────────────────
|
||
async function notifications() {
|
||
const res = await Nova.api('system', 'notify-settings');
|
||
const s = res?.data || {};
|
||
setTimeout(etLoadList, 80);
|
||
return `
|
||
<div class="page-header"><h2 class="page-title">Email Notifications</h2></div>
|
||
|
||
<div class="card mb-2">
|
||
<div class="card-header"><span class="card-title">CyberMail Settings</span></div>
|
||
<div class="card-body">
|
||
<form id="notify-form">
|
||
<div class="grid-2">
|
||
<div class="form-group" style="grid-column:1/-1">
|
||
<label>CyberMail API Key</label>
|
||
<input type="password" id="nf-apikey" name="cybermail_api_key" class="form-control" placeholder="${s.cybermail_api_key_masked || 'sk_live_…'}" value="">
|
||
<span class="form-hint">Leave blank to keep existing key. Get your key from platform.cyberpersons.com</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>From Email</label>
|
||
<input type="email" name="notify_from_email" class="form-control" value="${s.notify_from_email || ''}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>From Name</label>
|
||
<input type="text" name="notify_from_name" class="form-control" value="${s.notify_from_name || 'NovaCPX Panel'}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Admin Alert Email</label>
|
||
<input type="email" name="notify_admin_email" class="form-control" value="${s.notify_admin_email || ''}">
|
||
<span class="form-hint">Receives alerts for new accounts, suspensions, disk warnings</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Notifications</label>
|
||
<select name="notifications_enabled" class="form-control">
|
||
<option value="1" ${(s.notifications_enabled ?? '1') !== '0' ? 'selected' : ''}>Enabled</option>
|
||
<option value="0" ${s.notifications_enabled === '0' ? 'selected' : ''}>Disabled</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:.5rem;align-items:center">
|
||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||
<button type="button" class="btn btn-ghost" onclick="notifyTest()">Send Test Email</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Email Templates</span>
|
||
<button class="btn btn-sm btn-primary ml-auto" onclick="etNew()">+ New Template</button>
|
||
</div>
|
||
<div class="card-body" id="et-list-body">
|
||
<div class="loading">Loading templates…</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
document.addEventListener('submit', async e => {
|
||
if (!e.target.matches('#notify-form')) return;
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const body = Object.fromEntries(fd.entries());
|
||
if (!body.cybermail_api_key) delete body.cybermail_api_key;
|
||
const res = await Nova.api('system', 'save-notify-settings', { method: 'POST', body });
|
||
if (res?.success) Nova.toast('Notification settings saved', 'success');
|
||
else Nova.toast(res?.message || 'Save failed', 'error');
|
||
});
|
||
|
||
window.notifyTest = async () => {
|
||
const email = prompt('Send test email to:');
|
||
if (!email) return;
|
||
const res = await Nova.api('system', 'test-notify', { method: 'POST', body: { to: email } });
|
||
if (res?.success) Nova.toast(res.message, 'success');
|
||
else Nova.toast(res?.message || 'Send failed', 'error');
|
||
};
|
||
|
||
// ── Email Template Management ──────────────────────────────────────────────
|
||
window.etLoadList = async () => {
|
||
const body = document.getElementById('et-list-body');
|
||
if (!body) return;
|
||
body.innerHTML = '<div class="loading">Loading templates…</div>';
|
||
const r = await Nova.api('system', 'email-templates');
|
||
const tmpls = r?.data?.templates || [];
|
||
if (!tmpls.length) {
|
||
body.innerHTML = '<p class="text-muted">No templates found. <a href="#" onclick="etNew()">Create one</a>.</p>';
|
||
return;
|
||
}
|
||
body.innerHTML = `<div style="overflow-x:auto"><table class="table">
|
||
<thead><tr><th>Trigger</th><th>Label</th><th>Subject</th><th>Status</th><th>Actions</th></tr></thead>
|
||
<tbody>${tmpls.map(t => `<tr>
|
||
<td><code style="font-size:.78rem">${Nova.escHtml(t.trigger_key)}</code></td>
|
||
<td>${Nova.escHtml(t.label)}</td>
|
||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${Nova.escHtml(t.subject)}</td>
|
||
<td>${t.enabled ? Nova.badge('enabled','green') : Nova.badge('disabled','red')}</td>
|
||
<td style="white-space:nowrap">
|
||
<button class="btn btn-xs btn-ghost" onclick="etEdit(${t.id})">Edit</button>
|
||
<button class="btn btn-xs btn-ghost" onclick="etSendTest(${t.id})">Test</button>
|
||
<button class="btn btn-xs" style="color:var(--red)" onclick="etDelete(${t.id},'${Nova.escHtml(t.label)}')">Delete</button>
|
||
</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>`;
|
||
};
|
||
|
||
window.etEdit = async (id) => {
|
||
const r = await Nova.api('system', 'email-template-get', { method: 'POST', body: { id } });
|
||
if (!r?.success) { Nova.toast(r?.message || 'Load failed', 'error'); return; }
|
||
const t = r.data;
|
||
Nova.modal(id ? `Edit Template: ${t.label}` : 'New Template', `
|
||
<div class="form-group"><label>Subject</label>
|
||
<input id="et-subject" class="form-control" value="${Nova.escHtml(t.subject)}">
|
||
</div>
|
||
<div class="form-group"><label>HTML Body <span class="text-muted" style="font-size:.78rem">— use {{name}}, {{domain}}, {{username}}, etc.</span></label>
|
||
<textarea id="et-html" class="form-control" style="font-family:monospace;font-size:.8rem;height:220px">${Nova.escHtml(t.body_html)}</textarea>
|
||
</div>
|
||
<div class="form-group"><label>Plain Text Body <span class="text-muted" style="font-size:.78rem">(optional fallback)</span></label>
|
||
<textarea id="et-text" class="form-control" style="height:80px;font-size:.8rem">${Nova.escHtml(t.body_text || '')}</textarea>
|
||
</div>
|
||
<div class="form-group"><label>Status</label>
|
||
<select id="et-enabled" class="form-control" style="width:auto">
|
||
<option value="1" ${t.enabled ? 'selected' : ''}>Enabled</option>
|
||
<option value="0" ${!t.enabled ? 'selected' : ''}>Disabled</option>
|
||
</select>
|
||
</div>`,
|
||
`<button class="btn btn-primary" onclick="etSave(${id})">Save Template</button>
|
||
<button class="btn btn-ghost" onclick="document.querySelector('.modal-overlay')?.remove()">Cancel</button>`
|
||
);
|
||
};
|
||
|
||
window.etNew = () => {
|
||
Nova.modal('New Email Template', `
|
||
<div class="form-group"><label>Trigger Key <span class="text-muted" style="font-size:.78rem">(snake_case, unique)</span></label>
|
||
<input id="et-trigger" class="form-control" placeholder="e.g. account_upgraded">
|
||
</div>
|
||
<div class="form-group"><label>Label</label>
|
||
<input id="et-label" class="form-control" placeholder="Friendly name">
|
||
</div>
|
||
<div class="form-group"><label>Subject</label>
|
||
<input id="et-subject" class="form-control" placeholder="Email subject line">
|
||
</div>
|
||
<div class="form-group"><label>HTML Body</label>
|
||
<textarea id="et-html" class="form-control" style="font-family:monospace;font-size:.8rem;height:200px" placeholder="<h2>Hello {{name}}</h2><p>...</p>"></textarea>
|
||
</div>
|
||
<div class="form-group"><label>Plain Text Body <span class="text-muted" style="font-size:.78rem">(optional)</span></label>
|
||
<textarea id="et-text" class="form-control" style="height:70px;font-size:.8rem"></textarea>
|
||
</div>`,
|
||
`<button class="btn btn-primary" onclick="etSave(0)">Create Template</button>
|
||
<button class="btn btn-ghost" onclick="document.querySelector('.modal-overlay')?.remove()">Cancel</button>`
|
||
);
|
||
};
|
||
|
||
window.etSave = async (id) => {
|
||
const subject = document.getElementById('et-subject')?.value?.trim();
|
||
const body_html = document.getElementById('et-html')?.value?.trim();
|
||
const body_text = document.getElementById('et-text')?.value?.trim();
|
||
const enabled = document.getElementById('et-enabled')?.value ?? '1';
|
||
if (!subject || !body_html) { Nova.toast('Subject and HTML body required', 'error'); return; }
|
||
const extra = id ? {} : {
|
||
trigger_key: document.getElementById('et-trigger')?.value?.trim(),
|
||
label: document.getElementById('et-label')?.value?.trim(),
|
||
};
|
||
const r = await Nova.api('system', 'email-template-save', { method: 'POST', body: { id, subject, body_html, body_text, enabled, ...extra } });
|
||
if (r?.success) {
|
||
Nova.toast('Template saved', 'success');
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
etLoadList();
|
||
} else { Nova.toast(r?.message || 'Save failed', 'error'); }
|
||
};
|
||
|
||
window.etDelete = (id, label) => {
|
||
Nova.confirm(`Delete template "${label}"? This cannot be undone.`, async () => {
|
||
const r = await Nova.api('system', 'email-template-delete', { method: 'POST', body: { id } });
|
||
if (r?.success) { Nova.toast('Template deleted', 'success'); etLoadList(); }
|
||
else Nova.toast(r?.message || 'Delete failed', 'error');
|
||
}, true);
|
||
};
|
||
|
||
window.etSendTest = async (id) => {
|
||
const email = prompt('Send test email to:');
|
||
if (!email) return;
|
||
const r = await Nova.api('system', 'email-template-test', { method: 'POST', body: { id, to: email } });
|
||
if (r?.success) Nova.toast(r.message, 'success');
|
||
else Nova.toast(r?.message || 'Send failed', 'error');
|
||
};
|
||
|
||
// ── Settings ───────────────────────────────────────────────────────────────
|
||
async function settings() {
|
||
const r = await Nova.api('system', 'server-options');
|
||
const o = r?.data || {};
|
||
const cur = {
|
||
panel_name: o.panel_name || 'NovaCPX',
|
||
default_php: o.default_php || '8.3',
|
||
ns1: o.default_nameserver1 || '',
|
||
ns2: o.default_nameserver2 || '',
|
||
channel: o.update_channel || 'stable',
|
||
};
|
||
const phpOpts = ['7.4','8.1','8.2','8.3'].map(v =>
|
||
`<option value="${v}" ${v === cur.default_php ? 'selected' : ''}>${v}</option>`).join('');
|
||
const chanOpts = [
|
||
['stable', 'Stable — major releases (main branch)'],
|
||
['beta', 'Beta — minor & patch releases (beta branch)'],
|
||
].map(([v, l]) => `<option value="${v}" ${v === cur.channel ? 'selected' : ''}>${l}</option>`).join('');
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">Panel Settings</span></div>
|
||
<div class="card-body">
|
||
<form id="settings-form" onsubmit="event.preventDefault();adminSaveSettings()">
|
||
<div class="grid-2">
|
||
<div class="form-group"><label>Panel Name</label><input type="text" id="sf-panel-name" value="${Nova.escHtml(cur.panel_name)}"></div>
|
||
<div class="form-group"><label>Default PHP Version</label>
|
||
<select id="sf-default-php">${phpOpts}</select>
|
||
</div>
|
||
<div class="form-group"><label>Primary Nameserver</label><input type="text" id="sf-ns1" value="${Nova.escHtml(cur.ns1)}" placeholder="ns1.example.com"></div>
|
||
<div class="form-group"><label>Secondary Nameserver</label><input type="text" id="sf-ns2" value="${Nova.escHtml(cur.ns2)}" placeholder="ns2.example.com"></div>
|
||
<div class="form-group" style="grid-column:1/-1">
|
||
<label>Update Channel</label>
|
||
<select id="sf-channel">${chanOpts}</select>
|
||
<div class="form-hint" style="margin-top:.35rem;font-size:.77rem;color:var(--text-muted)">
|
||
<strong>Stable</strong> receives major releases pushed to <code>main</code>.
|
||
<strong>Beta</strong> tracks the <code>beta</code> branch for minor & patch releases.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||
</form>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
window.adminSaveSettings = async () => {
|
||
const btn = document.querySelector('#settings-form button[type=submit]');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
||
const saves = [
|
||
['panel_name', document.getElementById('sf-panel-name')?.value?.trim()],
|
||
['default_php', document.getElementById('sf-default-php')?.value],
|
||
['default_nameserver1',document.getElementById('sf-ns1')?.value?.trim()],
|
||
['default_nameserver2',document.getElementById('sf-ns2')?.value?.trim()],
|
||
['update_channel', document.getElementById('sf-channel')?.value],
|
||
].filter(([, v]) => v != null);
|
||
let ok = true;
|
||
for (const [key, value] of saves) {
|
||
const res = await Nova.api('system', 'save-option', { method: 'POST', body: { key, value } });
|
||
if (!res?.success) { ok = false; Nova.toast(`Failed to save ${key}`, 'error'); break; }
|
||
}
|
||
if (ok) Nova.toast('Settings saved', 'success');
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Save Settings'; }
|
||
};
|
||
|
||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||
async function accounts() {
|
||
const res = await Nova.api('accounts', 'list');
|
||
const accts = res?.data || [];
|
||
window._adminAccts = accts;
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">All Hosting Accounts</span>
|
||
<div style="display:flex;gap:.5rem">
|
||
<input id="acct-search" class="form-control" style="width:200px" placeholder="Search…" oninput="adminSearchAccounts(this.value)">
|
||
<button class="btn btn-primary btn-sm" onclick="adminPage('create-account')">+ Create</button>
|
||
</div>
|
||
</div>
|
||
<div id="admin-acct-table">
|
||
${renderAccountTable(accts)}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderAccountTable(accts) {
|
||
if (!accts.length) return '<div class="empty" style="padding:2rem">No accounts found.</div>';
|
||
return `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Owner</th><th>Package</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||
${accts.map(a => `<tr>
|
||
<td><strong>${Nova.escHtml(a.username)}</strong></td>
|
||
<td>${Nova.escHtml(a.domain)}</td>
|
||
<td class="text-sm">${a.reseller_username ? `<span class="badge badge-blue">${Nova.escHtml(a.reseller_username)}</span>` : '<span class="text-muted">Admin</span>'}</td>
|
||
<td>${a.package_name ? Nova.escHtml(a.package_name) : '<span class="text-muted">—</span>'}</td>
|
||
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
||
<td class="text-muted text-sm">${Nova.relTime(a.created_at)}</td>
|
||
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
||
<button class="btn btn-xs btn-primary" onclick="adminLoginAs(${a.user_id},'${Nova.escHtml(a.username)}')">Login As</button>
|
||
<button class="btn btn-xs" onclick="adminEditAccount(${a.id})">Edit</button>
|
||
${a.status==='active'
|
||
? `<button class="btn btn-xs btn-warning" onclick="adminSuspend(${a.id},'${a.username}')">Suspend</button>`
|
||
: `<button class="btn btn-xs btn-success" onclick="adminUnsuspend(${a.id})">Unsuspend</button>`}
|
||
<button class="btn btn-xs" onclick="adminChangePass(${a.id},'${a.username}')">Passwd</button>
|
||
<button class="btn btn-xs btn-danger" onclick="adminTerminate(${a.id},'${a.username}')">Terminate</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table>`;
|
||
}
|
||
|
||
window.adminLoginAs = async (userId, username) => {
|
||
Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => {
|
||
Nova.loading(`Switching to ${username}…`);
|
||
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
|
||
Nova.loadingDone();
|
||
if (res?.success) {
|
||
window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
|
||
} else {
|
||
Nova.toast(res?.message || 'Impersonation failed', 'error');
|
||
}
|
||
});
|
||
};
|
||
|
||
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 || []);
|
||
};
|
||
window.adminSuspend = async (id, user) => {
|
||
Nova.confirm(`Suspend ${user}?`, async () => {
|
||
const res = await Nova.api('accounts','suspend',{method:'POST',body:{account_id:id}});
|
||
if (res?.success) { Nova.toast('Suspended','success'); adminPage('accounts'); }
|
||
else Nova.toast(res?.message,'error');
|
||
});
|
||
};
|
||
window.adminUnsuspend = async (id) => {
|
||
const res = await Nova.api('accounts','unsuspend',{method:'POST',body:{account_id:id}});
|
||
if (res?.success) { Nova.toast('Unsuspended','success'); adminPage('accounts'); }
|
||
else Nova.toast(res?.message,'error');
|
||
};
|
||
window.adminChangePass = (id, user) => {
|
||
Nova.modal(`Change Password — ${user}`, `<div class="form-group"><label class="form-label">New Password</label><input id="acp-pass" type="password" class="form-control"></div>`,
|
||
`<button class="btn btn-primary" onclick="Nova.api('accounts','change-password',{method:'POST',body:{account_id:${id},password:document.getElementById('acp-pass').value}}).then(r=>{if(r?.success){Nova.toast('Updated','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Update</button>`);
|
||
};
|
||
window.adminTerminate = (id, user) => {
|
||
Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, DBs, DNS, email. IRREVERSIBLE.`, async () => {
|
||
const res = await Nova.api('accounts','terminate',{method:'POST',body:{account_id:id}});
|
||
if (res?.success) { Nova.toast('Terminated','success'); adminPage('accounts'); }
|
||
else Nova.toast(res?.message,'error');
|
||
}, true);
|
||
};
|
||
|
||
window.adminEditAccount = async (id) => {
|
||
Nova.loading('Loading account…');
|
||
const [acctRes, pkgRes, usersRes, dnsRes] = await Promise.all([
|
||
Nova.api('accounts', 'get', { params: { id } }),
|
||
Nova.api('packages', 'list'),
|
||
Nova.api('users', 'list', { params: { role: 'reseller' } }),
|
||
Nova.api('dns', 'zones'),
|
||
]);
|
||
Nova.loadingDone();
|
||
if (!acctRes?.success) { Nova.toast(acctRes?.message || 'Failed to load account', 'error'); return; }
|
||
const a = acctRes.data;
|
||
const pkgs = pkgRes?.data || [];
|
||
const resellers = (usersRes?.data || []).filter(u => u.role === 'reseller');
|
||
const zone = (dnsRes?.data || []).find(z => z.account_id == id || z.domain === a.domain);
|
||
|
||
const pkgOpts = `<option value="">— No package —</option>` +
|
||
pkgs.map(p => `<option value="${p.id}" ${a.package_id == p.id ? 'selected' : ''}>${Nova.escHtml(p.name)}</option>`).join('');
|
||
const phpOpts = ['8.3','8.2','8.1','7.4'].map(v =>
|
||
`<option value="${v}" ${a.php_version === v ? 'selected' : ''}>PHP ${v}</option>`).join('');
|
||
const ownerOpts = `<option value="">— Admin (no reseller) —</option>` +
|
||
resellers.map(r => `<option value="${r.id}" ${a.reseller_id == r.id ? 'selected' : ''}>${Nova.escHtml(r.username)}</option>`).join('');
|
||
|
||
Nova.modal(`Edit Account — ${Nova.escHtml(a.username)}`,
|
||
`<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||
<div class="form-group"><label class="form-label">Username</label>
|
||
<input class="form-control" value="${Nova.escHtml(a.username)}" disabled></div>
|
||
<div class="form-group"><label class="form-label">Domain</label>
|
||
<input class="form-control" value="${Nova.escHtml(a.domain)}" disabled></div>
|
||
<div class="form-group"><label class="form-label">Email</label>
|
||
<input id="ae-email" class="form-control" type="email" value="${Nova.escHtml(a.email || '')}"></div>
|
||
<div class="form-group"><label class="form-label">Owner (Reseller)</label>
|
||
<select id="ae-owner" class="form-control">${ownerOpts}</select></div>
|
||
<div class="form-group"><label class="form-label">Package</label>
|
||
<select id="ae-pkg" class="form-control">${pkgOpts}</select></div>
|
||
<div class="form-group"><label class="form-label">PHP Version</label>
|
||
<select id="ae-php" class="form-control">${phpOpts}</select></div>
|
||
</div>
|
||
<div style="margin-top:.75rem;padding:.75rem;background:var(--bg2);border-radius:8px;border:1px solid var(--border)">
|
||
<div style="font-size:.78rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.5rem">DNS Zone — ${Nova.escHtml(a.domain)}</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||
<div class="form-group" style="margin:0"><label class="form-label" style="font-size:.78rem">Primary NS</label>
|
||
<input id="ae-ns1" class="form-control form-control-sm" value="${Nova.escHtml(zone?.primary_ns || '')}"></div>
|
||
<div class="form-group" style="margin:0"><label class="form-label" style="font-size:.78rem">Secondary NS</label>
|
||
<input id="ae-ns2" class="form-control form-control-sm" value="${Nova.escHtml(zone?.secondary_ns || '')}"></div>
|
||
</div>
|
||
${zone ? `<div style="margin-top:.4rem;font-size:.72rem;color:var(--text-muted)">Zone ID: ${zone.id} · Serial: ${zone.serial}</div>` : '<div style="font-size:.72rem;color:var(--red);margin-top:.4rem">No DNS zone found for this account</div>'}
|
||
</div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="adminEditAccountSave(${id})">Save Changes</button>`
|
||
);
|
||
};
|
||
|
||
window.adminEditAccountSave = async (id) => {
|
||
const body = {
|
||
id,
|
||
email: document.getElementById('ae-email')?.value?.trim(),
|
||
reseller_id: document.getElementById('ae-owner')?.value || null,
|
||
package_id: document.getElementById('ae-pkg')?.value || null,
|
||
php_version: document.getElementById('ae-php')?.value,
|
||
ns1: document.getElementById('ae-ns1')?.value?.trim(),
|
||
ns2: document.getElementById('ae-ns2')?.value?.trim(),
|
||
};
|
||
Nova.loading('Saving account…');
|
||
const res = await Nova.api('accounts', 'update', { method: 'POST', body });
|
||
Nova.loadingDone();
|
||
if (res?.success) {
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
Nova.toast('Account updated', 'success');
|
||
adminPage('accounts');
|
||
} else {
|
||
Nova.toast(res?.message || 'Update failed', 'error');
|
||
}
|
||
};
|
||
|
||
// ── Create Account ─────────────────────────────────────────────────────────
|
||
async function createAccount() {
|
||
const pkgRes = await Nova.api('packages', 'list');
|
||
const pkgOpts = (pkgRes?.data || []).map(p => `<option value="${p.id}">${p.name} — ${p.disk_mb}MB</option>`).join('');
|
||
return `
|
||
<div class="card" style="max-width:640px">
|
||
<div class="card-header"><span class="card-title">Create Hosting Account</span></div>
|
||
<div style="padding:1.5rem">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||
<div class="form-group"><label class="form-label">Username *</label><input id="nca-user" class="form-control" placeholder="lowercase, no spaces"></div>
|
||
<div class="form-group"><label class="form-label">Password *</label><input id="nca-pass" type="password" class="form-control"></div>
|
||
<div class="form-group"><label class="form-label">Email</label><input id="nca-email" type="email" class="form-control"></div>
|
||
<div class="form-group"><label class="form-label">Domain *</label><input id="nca-domain" class="form-control" placeholder="example.com"></div>
|
||
<div class="form-group"><label class="form-label">Package</label><select id="nca-pkg" class="form-control">${pkgOpts}</select></div>
|
||
<div class="form-group"><label class="form-label">PHP Version</label>
|
||
<select id="nca-php" class="form-control">
|
||
${['8.3','8.2','8.1','7.4'].map(v => `<option value="${v}">PHP ${v}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:1.25rem;display:flex;gap:.75rem">
|
||
<button class="btn btn-primary" onclick="adminSubmitCreateAccount()">Create Account</button>
|
||
<button class="btn" onclick="adminPage('accounts')">Cancel</button>
|
||
</div>
|
||
<div id="nca-result" style="margin-top:1rem"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
window.adminSubmitCreateAccount = async () => {
|
||
const res = await Nova.api('accounts','create',{method:'POST',body:{
|
||
username: document.getElementById('nca-user')?.value,
|
||
password: document.getElementById('nca-pass')?.value,
|
||
email: document.getElementById('nca-email')?.value,
|
||
domain: document.getElementById('nca-domain')?.value,
|
||
package_id: document.getElementById('nca-pkg')?.value,
|
||
php_version:document.getElementById('nca-php')?.value,
|
||
}});
|
||
const el = document.getElementById('nca-result');
|
||
if (res?.success) {
|
||
Nova.toast('Account created!','success');
|
||
if (el) el.innerHTML = `<div class="alert alert-success">Account created successfully! <a href="#" onclick="adminPage('accounts')">View accounts →</a></div>`;
|
||
} else {
|
||
Nova.toast(res?.message || 'Failed','error');
|
||
if (el) el.innerHTML = `<div class="alert alert-error">${res?.message || 'Error creating account'}</div>`;
|
||
}
|
||
};
|
||
|
||
// ── Resellers ──────────────────────────────────────────────────────────────
|
||
async function resellers() {
|
||
const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }});
|
||
const rows = res?.data || [];
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Reseller Accounts</span>
|
||
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
|
||
</div>
|
||
<div id="reseller-table">
|
||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||
${rows.map(r => `<tr>
|
||
<td><strong>${Nova.escHtml(r.username)}</strong></td>
|
||
<td>${Nova.escHtml(r.email||'—')}</td>
|
||
<td>${Nova.badge(r.status, r.status==='active'?'green':'red')}</td>
|
||
<td class="text-sm text-muted">${r.created_at ? r.created_at.slice(0,10) : '—'}</td>
|
||
<td style="display:flex;gap:.25rem">
|
||
<button class="btn btn-xs" onclick="adminResellerPasswd(${r.id},'${Nova.escHtml(r.username)}')">Passwd</button>
|
||
${r.status === 'active'
|
||
? `<button class="btn btn-xs btn-warning" onclick="adminResellerSuspend(${r.id},'${Nova.escHtml(r.username)}')">Suspend</button>`
|
||
: `<button class="btn btn-xs btn-success" onclick="adminResellerUnsuspend(${r.id})">Unsuspend</button>`}
|
||
<button class="btn btn-xs btn-danger" onclick="adminResellerDelete(${r.id},'${Nova.escHtml(r.username)}')">Delete</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table>`
|
||
: '<div class="empty" style="padding:2rem">No resellers yet.</div>'}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
window.adminAddReseller = () => {
|
||
Nova.modal('Create Reseller Account', `
|
||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control" autocomplete="off"></div>
|
||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>
|
||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||
`<button class="btn btn-primary" onclick="
|
||
Nova.api('users','create',{method:'POST',body:{
|
||
username:document.getElementById('ar-user').value,
|
||
email:document.getElementById('ar-email').value,
|
||
password:document.getElementById('ar-pass').value,
|
||
role:'reseller'
|
||
}}).then(r=>{
|
||
if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}
|
||
else Nova.toast(r?.message||'Error','error');
|
||
})">Create</button>`);
|
||
};
|
||
|
||
window.adminResellerPasswd = (id, user) => {
|
||
Nova.modal(`Change Password — ${user}`,
|
||
`<div class="form-group"><label class="form-label">New Password</label><input id="arp-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||
`<button class="btn btn-primary" onclick="
|
||
Nova.api('users','change-password',{method:'POST',body:{id:${id},password:document.getElementById('arp-pass').value}}).then(r=>{
|
||
if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}
|
||
else Nova.toast(r?.message||'Error','error');
|
||
})">Update</button>`);
|
||
};
|
||
|
||
window.adminResellerSuspend = (id, user) => {
|
||
Nova.confirm(`Suspend reseller ${user}?`, async () => {
|
||
const r = await Nova.api('users','suspend',{method:'POST',body:{id}});
|
||
if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); }
|
||
else Nova.toast(r?.message||'Error','error');
|
||
});
|
||
};
|
||
|
||
window.adminResellerUnsuspend = async (id) => {
|
||
const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}});
|
||
if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); }
|
||
else Nova.toast(r?.message||'Error','error');
|
||
};
|
||
|
||
window.adminResellerDelete = (id, user) => {
|
||
Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => {
|
||
const r = await Nova.api('users','delete',{method:'POST',body:{id}});
|
||
if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); }
|
||
else Nova.toast(r?.message||'Error','error');
|
||
}, true);
|
||
};
|
||
|
||
// ── Packages ───────────────────────────────────────────────────────────────
|
||
async function packages() {
|
||
const res = await Nova.api('packages', 'list');
|
||
const pkgs = res?.data || [];
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Hosting Packages</span>
|
||
<button class="btn btn-primary btn-sm" onclick="adminAddPkg()">+ Add Package</button>
|
||
</div>
|
||
${pkgs.length ? `<table class="table"><thead><tr><th>Name</th><th>Disk</th><th>BW</th><th>DBs</th><th>Emails</th><th>Price</th><th>Accounts</th><th>Actions</th></tr></thead><tbody>
|
||
${pkgs.map(p => `<tr>
|
||
<td><strong>${p.name}</strong></td>
|
||
<td>${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'}</td>
|
||
<td>${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'}</td>
|
||
<td>${p.databases||'∞'}</td>
|
||
<td>${p.email_accounts||'∞'}</td>
|
||
<td>${p.price ? '$'+p.price : 'Free'}</td>
|
||
<td>${p.account_count||0}</td>
|
||
<td style="display:flex;gap:.25rem">
|
||
<button class="btn btn-xs" onclick="adminEditPkg(${p.id})">Edit</button>
|
||
<button class="btn btn-xs btn-danger" onclick="adminDelPkg(${p.id},'${p.name}')">Del</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table>`
|
||
: '<div class="empty" style="padding:2rem">No packages yet. Create one to start hosting accounts.</div>'}
|
||
</div>`;
|
||
}
|
||
|
||
window.adminAddPkg = () => showAdminPkgModal();
|
||
window.adminEditPkg = async (id) => {
|
||
const r = await Nova.api('packages','get',{params:{id}});
|
||
if (r?.success) showAdminPkgModal(r.data);
|
||
};
|
||
function showAdminPkgModal(p = {}) {
|
||
Nova.modal(p.id ? 'Edit Package' : 'Add Package', `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||
<div class="form-group" style="grid-column:1/-1"><label class="form-label">Name</label><input id="ap-name" class="form-control" value="${p.name||''}"></div>
|
||
<div class="form-group"><label class="form-label">Disk (MB)</label><input id="ap-disk" type="number" class="form-control" value="${p.disk_mb||0}"></div>
|
||
<div class="form-group"><label class="form-label">Bandwidth (MB)</label><input id="ap-bw" type="number" class="form-control" value="${p.bandwidth_mb||0}"></div>
|
||
<div class="form-group"><label class="form-label">Databases</label><input id="ap-db" type="number" class="form-control" value="${p.databases||0}"></div>
|
||
<div class="form-group"><label class="form-label">Email Accounts</label><input id="ap-email" type="number" class="form-control" value="${p.email_accounts||0}"></div>
|
||
<div class="form-group"><label class="form-label">Addon Domains</label><input id="ap-dom" type="number" class="form-control" value="${p.addon_domains||0}"></div>
|
||
<div class="form-group"><label class="form-label">Subdomains</label><input id="ap-sub" type="number" class="form-control" value="${p.subdomains||0}"></div>
|
||
<div class="form-group"><label class="form-label">FTP Accounts</label><input id="ap-ftp" type="number" class="form-control" value="${p.ftp_accounts||0}"></div>
|
||
<div class="form-group"><label class="form-label">Price ($/mo)</label><input id="ap-price" type="number" step="0.01" class="form-control" value="${p.price||0}"></div>
|
||
</div>`,
|
||
`<button class="btn btn-primary" onclick="adminSavePkg(${p.id||'null'})">Save</button>`);
|
||
}
|
||
window.adminSavePkg = async (id) => {
|
||
const body = {name:document.getElementById('ap-name')?.value,disk_mb:+document.getElementById('ap-disk')?.value,bandwidth_mb:+document.getElementById('ap-bw')?.value,databases:+document.getElementById('ap-db')?.value,email_accounts:+document.getElementById('ap-email')?.value,addon_domains:+document.getElementById('ap-dom')?.value,subdomains:+document.getElementById('ap-sub')?.value,ftp_accounts:+document.getElementById('ap-ftp')?.value,price:+document.getElementById('ap-price')?.value};
|
||
const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body});
|
||
if (res?.success) { Nova.toast(id?'Updated':'Created','success'); document.querySelector('.modal-overlay')?.remove(); adminPage('packages'); }
|
||
else Nova.toast(res?.message,'error');
|
||
};
|
||
window.adminDelPkg = (id, name) => {
|
||
Nova.confirm(`Delete package "${name}"?`, async () => {
|
||
const r = await Nova.api('packages','delete',{method:'POST',body:{id}});
|
||
if (r?.success) { Nova.toast('Deleted','success'); adminPage('packages'); }
|
||
else Nova.toast(r?.message,'error');
|
||
}, true);
|
||
};
|
||
|
||
// ── DNS Zones ──────────────────────────────────────────────────────────────
|
||
async function dnsZones() {
|
||
const res = await Nova.api('dns', 'zones');
|
||
const zones = res?.data || [];
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">DNS Zones</span>
|
||
<button class="btn btn-sm" onclick="adminAddZone()">+ Add Zone</button>
|
||
</div>
|
||
${zones.length ? `<table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Records</th><th>Actions</th></tr></thead><tbody>
|
||
${zones.map(z => `<tr>
|
||
<td>${z.domain}</td>
|
||
<td>${z.username||'—'}</td>
|
||
<td>${z.record_count||0}</td>
|
||
<td style="display:flex;gap:.25rem">
|
||
<button class="btn btn-xs" onclick="adminEditZone(${z.id},'${z.domain}')">Records</button>
|
||
<button class="btn btn-xs btn-danger" onclick="adminDelZone(${z.id},'${z.domain}')">Del</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table>`
|
||
: '<div class="empty" style="padding:2rem">No DNS zones yet.</div>'}
|
||
</div>`;
|
||
}
|
||
|
||
window.adminAddZone = () => {
|
||
Nova.modal('Create DNS Zone', `<div class="form-group"><label class="form-label">Domain</label><input id="az-dom" class="form-control" placeholder="example.com"></div>`,
|
||
`<button class="btn btn-primary" onclick="Nova.api('dns','create-zone',{method:'POST',body:{domain:document.getElementById('az-dom').value}}).then(r=>{if(r?.success){Nova.toast('Zone created','success');document.querySelector('.modal-overlay').remove();adminPage('dns-zones');}else Nova.toast(r?.message,'error');})">Create</button>`);
|
||
};
|
||
window.adminEditZone = async (id, domain) => {
|
||
const res = await Nova.api('dns', 'records', {params:{zone_id:id}});
|
||
if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
|
||
const records = Array.isArray(res.data) ? res.data : [];
|
||
const rows = records.map(r => `<tr><td>${Nova.escHtml(r.name)}</td><td>${Nova.badge(r.type,'default')}</td><td style="max-width:220px;overflow:hidden;text-overflow:ellipsis"><code>${Nova.escHtml(r.content||r.value||'')}</code></td><td>${r.ttl||3600}</td>
|
||
<td><button class="btn btn-xs btn-danger" onclick="adminDelRecord(${r.id},${id},'${Nova.escHtml(domain)}')">Del</button></td></tr>`).join('');
|
||
Nova.modal(`DNS: ${domain}`, `
|
||
<button class="btn btn-sm btn-primary" style="margin-bottom:.75rem" onclick="adminAddRecord(${id},'${domain}')">+ Add Record</button>
|
||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Type</th><th>Content</th><th>TTL</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="5" class="text-muted" style="text-align:center;padding:1rem">No records yet.</td></tr>'}</tbody></table></div>`);
|
||
};
|
||
window.adminAddRecord = (zoneId, domain) => {
|
||
Nova.modal('Add Record', `
|
||
<div class="form-group"><label class="form-label">Name</label><input id="ar2-name" class="form-control" value="@"></div>
|
||
<div class="form-group"><label class="form-label">Type</label><select id="ar2-type" class="form-control"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>NS</option><option>CAA</option></select></div>
|
||
<div class="form-group"><label class="form-label">Content</label><input id="ar2-val" class="form-control" placeholder="IP address, hostname, or value"></div>
|
||
<div class="form-group"><label class="form-label">TTL</label><input id="ar2-ttl" type="number" class="form-control" value="3600"></div>`,
|
||
`<button class="btn btn-primary" onclick="Nova.api('dns','add-record',{method:'POST',body:{zone_id:${zoneId},name:document.getElementById('ar2-name').value,type:document.getElementById('ar2-type').value,content:document.getElementById('ar2-val').value,ttl:parseInt(document.getElementById('ar2-ttl').value)||3600}}).then(r=>{if(r?.success){Nova.toast('Record added','success');document.querySelector('.modal-overlay').remove();adminEditZone(${zoneId},'${domain}');}else Nova.toast(r?.message||'Failed','error');})">Add Record</button>`);
|
||
};
|
||
window.adminDelRecord = async (id, zoneId, domain) => {
|
||
Nova.confirm('Delete this record?', async () => {
|
||
const r = await Nova.api('dns','delete-record',{method:'POST',body:{id}});
|
||
if (r?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); adminEditZone(zoneId,domain); }
|
||
else Nova.toast(r?.message,'error');
|
||
}, true);
|
||
};
|
||
window.adminDelZone = (id, domain) => {
|
||
Nova.confirm(`Delete DNS zone for ${domain}?`, async () => {
|
||
const r = await Nova.api('dns','delete-zone',{method:'POST',body:{zone_id:id}});
|
||
if (r?.success) { Nova.toast('Zone deleted','success'); adminPage('dns-zones'); }
|
||
else Nova.toast(r?.message,'error');
|
||
}, true);
|
||
};
|
||
|
||
// ── Nameservers ────────────────────────────────────────────────────────────
|
||
async function nameservers() {
|
||
const r = await Nova.api('server_setup','get');
|
||
const d = r?.data || {};
|
||
return `
|
||
<div class="card" style="max-width:500px">
|
||
<div class="card-header"><span class="card-title">Nameserver Configuration</span></div>
|
||
<div style="padding:1.5rem">
|
||
<div class="form-group"><label class="form-label">Primary Nameserver</label><input id="ns1" class="form-control" value="${d.nameserver1||''}"></div>
|
||
<div class="form-group"><label class="form-label">Secondary Nameserver</label><input id="ns2" class="form-control" value="${d.nameserver2||''}"></div>
|
||
<div class="form-group"><label class="form-label">Hostname</label><input id="srvhost" class="form-control" value="${d.hostname||d.system_hostname||''}"></div>
|
||
<div style="display:flex;gap:.75rem;margin-top:1rem">
|
||
<button class="btn btn-primary" onclick="adminSaveNS()">Save Nameservers</button>
|
||
<button class="btn" onclick="adminSetHostname()">Set Hostname</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
window.adminSaveNS = async () => {
|
||
const r = await Nova.api('server_setup','nameservers',{method:'POST',body:{ns1:document.getElementById('ns1')?.value,ns2:document.getElementById('ns2')?.value}});
|
||
if (r?.success) Nova.toast('Nameservers saved','success');
|
||
else Nova.toast(r?.message,'error');
|
||
};
|
||
window.adminSetHostname = async () => {
|
||
const r = await Nova.api('server_setup','set-hostname',{method:'POST',body:{hostname:document.getElementById('srvhost')?.value}});
|
||
if (r?.success) Nova.toast(`Hostname set to ${r.data?.hostname}`,'success');
|
||
else Nova.toast(r?.message,'error');
|
||
};
|
||
|
||
// ── Web Server ────────────────────────────────────────────────────────────
|
||
async function webServer() {
|
||
const [statsR, phpR] = await Promise.all([
|
||
Nova.api('system','stats'),
|
||
Nova.api('php','global-config'),
|
||
]);
|
||
const svcs = statsR?.data?.services || {};
|
||
const uptime = statsR?.data?.uptime || '—';
|
||
const cpu = statsR?.data?.cpu?.pct ?? '—';
|
||
const ram = statsR?.data?.ram?.pct ?? '—';
|
||
const phpCfg = phpR?.data || {};
|
||
|
||
window.wsLoadLog = async (log) => {
|
||
const r = await Nova.api('system','read-log',{params:{log}});
|
||
const el = document.getElementById('ws-log-out');
|
||
if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; }
|
||
};
|
||
|
||
return `
|
||
<div class="page-header mb-2"><h2 class="page-title">Web Server</h2></div>
|
||
<div class="stats-grid mb-3">
|
||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">${cpu}%</div></div>
|
||
<div class="stat-card"><div class="stat-label">RAM</div><div class="stat-value">${ram}%</div></div>
|
||
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-value stat-sm">${uptime}</div></div>
|
||
<div class="stat-card"><div class="stat-label">PHP</div><div class="stat-value stat-sm">${phpCfg.version||'8.3'}</div></div>
|
||
</div>
|
||
|
||
<div class="grid-2 mb-3">
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">Services</span></div>
|
||
<div class="card-body">
|
||
${Object.entries(svcs).map(([s,st]) => `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid var(--border)">
|
||
<span style="font-weight:500">${s} ${Nova.badge(st,st==='active'?'green':'red')}</span>
|
||
<div style="display:flex;gap:.35rem">
|
||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">↺ Restart</button>
|
||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')">⟳ Reload</button>
|
||
<button class="btn btn-xs btn-danger" onclick="adminServiceAction('${s}','stop')">■ Stop</button>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">PHP Defaults</span></div>
|
||
<div class="card-body">
|
||
${[['Version',phpCfg.version||'8.3'],['Memory Limit',phpCfg.memory_limit||'256M'],
|
||
['Upload Max',phpCfg.upload_max_filesize||'64M'],['Max Exec Time',(phpCfg.max_execution_time||30)+'s'],
|
||
['Post Max',phpCfg.post_max_size||'64M']].map(([k,v])=>`
|
||
<div style="display:flex;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--border);font-size:.85rem">
|
||
<span style="color:var(--text-muted)">${k}</span><strong>${v}</strong>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Log Viewer</span>
|
||
<div style="display:flex;gap:.35rem;margin-left:auto">
|
||
${[['nginx-error','Nginx Error'],['nginx-access','Nginx Access'],['panel','Panel'],['deploy','Deploy']].map(([l,n])=>
|
||
`<button class="btn btn-xs" onclick="wsLoadLog('${l}')">${n}</button>`).join('')}
|
||
</div>
|
||
</div>
|
||
<pre id="ws-log-out" style="background:var(--bg);padding:1rem;font-size:.78rem;height:220px;overflow:auto;margin:0;border-radius:0 0 var(--radius) var(--radius);color:var(--text-muted)">← Click a log above to view it</pre>
|
||
</div>`;
|
||
}
|
||
|
||
// ── SSL Manager ────────────────────────────────────────────────────────────
|
||
async function sslManager() {
|
||
const res = await Nova.api('ssl', 'list', {params:{account_id:0}});
|
||
const certs = res?.data || [];
|
||
return `
|
||
<div class="page-header"><h2 class="page-title">SSL Certificate Manager</h2></div>
|
||
|
||
<div class="card" style="margin-bottom:1.5rem">
|
||
<div class="card-header">
|
||
<span class="card-title">Certificates</span>
|
||
<div style="display:flex;gap:.5rem;margin-left:auto;flex-wrap:wrap">
|
||
<button class="btn btn-xs btn-ghost" onclick="adminGenerateCSR()">Generate CSR</button>
|
||
<button class="btn btn-xs btn-ghost" onclick="adminInstallCustomSSL()">Upload Custom SSL</button>
|
||
<button class="btn btn-xs btn-primary" onclick="adminIssueBulkSSL()">Issue LE for All Domains</button>
|
||
</div>
|
||
</div>
|
||
${certs.length ? `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Type</th><th>Expires</th><th>Days</th><th>Actions</th></tr></thead><tbody>
|
||
${certs.map(c => {
|
||
const days = c.days_remaining;
|
||
const badge = days !== null ? Nova.badge(days+'d', days<7?'red':days<30?'yellow':'green') : Nova.badge('unknown','muted');
|
||
return `<tr>
|
||
<td>${Nova.escHtml(c.domain)}</td>
|
||
<td>${Nova.escHtml(c.username||'—')}</td>
|
||
<td>${Nova.badge(c.type||'lets-encrypt','default')}</td>
|
||
<td>${c.expires_at||'—'}</td>
|
||
<td>${badge}</td>
|
||
<td style="display:flex;gap:.25rem">
|
||
<button class="btn btn-xs" onclick="adminRenewCert(${c.id})">Renew</button>
|
||
<button class="btn btn-xs btn-danger" onclick="adminDelCert(${c.id},'${Nova.escHtml(c.domain)}')">Del</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody></table></div>`
|
||
: '<div class="empty" style="padding:2rem">No SSL certificates yet.</div>'}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">About SSL Options</span></div>
|
||
<div class="card-body" style="font-size:.85rem;color:var(--text-muted)">
|
||
<p><strong>Let's Encrypt</strong> — Free automatic SSL via Certbot. Requires a publicly reachable domain (port 80 open). Use "Issue LE for All Domains" to auto-issue for every account.</p>
|
||
<p style="margin-top:.5rem"><strong>Custom SSL</strong> — Upload a certificate from any CA (Comodo, DigiCert, GlobalSign, etc). Paste the certificate, private key, and CA chain. Use "Generate CSR" to create a signing request to send to your CA.</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
const _sslStream = (domain, accountId, label) => {
|
||
const termId = 'ssl-term-' + Date.now();
|
||
Nova.modal(`SSL: ${label || domain}`, `
|
||
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
|
||
padding:1rem;border-radius:6px;height:260px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||
<span style="color:#7ec8e3">Requesting certificate…</span>\n
|
||
</div>`,
|
||
`<button class="btn btn-ghost" id="ssl-term-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
|
||
const term = document.getElementById(termId);
|
||
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
|
||
fetch('/api/ssl/issue', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ domain, account_id: accountId }),
|
||
credentials: 'same-origin',
|
||
}).then(resp => {
|
||
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
const read = () => reader.read().then(({ done, value }) => {
|
||
if (done) { append('\n[done]'); return; }
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const obj = JSON.parse(m[1]);
|
||
if (obj.line) { append(obj.line); }
|
||
else if (obj.done) {
|
||
const btn = document.getElementById('ssl-term-close');
|
||
if (btn) {
|
||
btn.textContent = obj.success ? 'Done ✓' : 'Close';
|
||
btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost';
|
||
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('ssl-manager'); };
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
read();
|
||
}).catch(err => append(`\n[error: ${err.message}]`));
|
||
read();
|
||
}).catch(err => append(`\nFetch error: ${err.message}`));
|
||
};
|
||
|
||
window.adminIssueBulkSSL = async () => {
|
||
const accts = await Nova.api('accounts','list',{params:{limit:1000}});
|
||
const domains = (accts?.data || []).map(a => ({domain: a.domain, id: a.id}));
|
||
if (!domains.length) { Nova.toast('No accounts found','error'); return; }
|
||
const termId = 'ssl-bulk-' + Date.now();
|
||
Nova.modal('Bulk SSL Issuance', `
|
||
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
|
||
padding:1rem;border-radius:6px;height:300px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||
<span style="color:#7ec8e3">Starting bulk SSL for ${domains.length} domains…</span>\n
|
||
</div>`,
|
||
`<button class="btn btn-ghost" id="ssl-bulk-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
|
||
const term = document.getElementById(termId);
|
||
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
|
||
let done = 0;
|
||
for (const a of domains) {
|
||
append(`\n[${++done}/${domains.length}] ${a.domain}…\n`);
|
||
try {
|
||
const r = await Nova.api('ssl','issue',{method:'POST',body:{domain:a.domain,account_id:a.id}});
|
||
append(r?.success ? ` ✓ Issued\n` : ` ✗ ${r?.message || 'failed'}\n`);
|
||
} catch(e) { append(` ✗ ${e.message}\n`); }
|
||
}
|
||
append(`\nDone. ${done} domains processed.\n`);
|
||
const btn = document.getElementById('ssl-bulk-close');
|
||
if (btn) { btn.textContent = 'Done'; btn.className = 'btn btn-primary'; btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('ssl-manager'); }; }
|
||
};
|
||
|
||
window.adminRenewCert = (id) => {
|
||
Nova.confirm('Renew this SSL certificate now?', async () => {
|
||
const r = await Nova.api('ssl','renew',{method:'POST',body:{cert_id:id}});
|
||
if (r?.success) { Nova.toast('Renewed','success'); adminPage('ssl-manager'); }
|
||
else Nova.toast(r?.message,'error');
|
||
});
|
||
};
|
||
|
||
window.adminIssueSingleSSL = (domain, accountId) => _sslStream(domain, accountId, domain);
|
||
window.adminDelCert = (id, domain) => {
|
||
Nova.confirm(`Delete SSL cert for ${domain}?`, async () => {
|
||
const r = await Nova.api('ssl','delete',{method:'POST',body:{cert_id:id}});
|
||
if (r?.success) { Nova.toast('Deleted','success'); adminPage('ssl-manager'); }
|
||
else Nova.toast(r?.message,'error');
|
||
}, true);
|
||
};
|
||
window.adminGenerateCSR = () => {
|
||
Nova.modal('Generate CSR', `
|
||
<p class="text-muted" style="font-size:.83rem;margin-bottom:1rem">Fill in your details. Submit to CA, keep the private key safe.</p>
|
||
<div class="grid-2">
|
||
<div class="form-group"><label>Domain (CN)</label><input id="csr-domain" class="form-control" placeholder="example.com"></div>
|
||
<div class="form-group"><label>Country (2-letter)</label><input id="csr-country" class="form-control" value="US" maxlength="2"></div>
|
||
<div class="form-group"><label>State / Province</label><input id="csr-state" class="form-control" placeholder="California"></div>
|
||
<div class="form-group"><label>City / Locality</label><input id="csr-city" class="form-control" placeholder="San Francisco"></div>
|
||
</div>
|
||
<div class="form-group"><label>Organization</label><input id="csr-org" class="form-control" placeholder="My Company LLC"></div>`,
|
||
`<button class="btn btn-primary" onclick="adminDoGenerateCSR()">Generate CSR</button>`);
|
||
};
|
||
window.adminDoGenerateCSR = async () => {
|
||
const domain = document.getElementById('csr-domain')?.value?.trim();
|
||
const country = document.getElementById('csr-country')?.value?.trim();
|
||
const state = document.getElementById('csr-state')?.value?.trim();
|
||
const city = document.getElementById('csr-city')?.value?.trim();
|
||
const org = document.getElementById('csr-org')?.value?.trim();
|
||
if (!domain) { Nova.toast('Domain required','error'); return; }
|
||
Nova.toast('Generating CSR…','info');
|
||
const r = await Nova.api('ssl','generate-csr',{method:'POST',body:{domain,country,state,city,org}});
|
||
if (!r?.success) { Nova.toast(r?.message||'Failed','error'); return; }
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
Nova.modal(`CSR for ${domain}`, `
|
||
<p class="text-muted" style="font-size:.82rem;margin-bottom:.75rem">Submit the CSR to your certificate authority. Store the private key securely — you'll need it when uploading the issued cert.</p>
|
||
<div class="form-group"><label>Certificate Signing Request (CSR)</label>
|
||
<textarea class="form-control" rows="8" readonly style="font-family:monospace;font-size:.75rem">${Nova.escHtml(r.data.csr)}</textarea></div>
|
||
<div class="form-group"><label>Private Key (keep secret)</label>
|
||
<textarea class="form-control" rows="8" readonly style="font-family:monospace;font-size:.75rem">${Nova.escHtml(r.data.private_key)}</textarea></div>
|
||
<button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(document.querySelectorAll('.modal-overlay textarea')[0].value).then(()=>Nova.toast('CSR copied','success'))">Copy CSR</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(document.querySelectorAll('.modal-overlay textarea')[1].value).then(()=>Nova.toast('Key copied','success'))">Copy Key</button>`);
|
||
};
|
||
window.adminInstallCustomSSL = () => {
|
||
Nova.modal('Upload Custom SSL Certificate', `
|
||
<p class="text-muted" style="font-size:.82rem;margin-bottom:.75rem">Paste the certificate and key from your CA. Chain/CA bundle is optional but recommended.</p>
|
||
<div class="form-group"><label>Domain</label><input id="cssl-domain" class="form-control" placeholder="example.com"></div>
|
||
<div class="form-group"><label>Certificate (PEM)</label><textarea id="cssl-cert" class="form-control" rows="5" style="font-family:monospace;font-size:.75rem" placeholder="-----BEGIN CERTIFICATE-----"></textarea></div>
|
||
<div class="form-group"><label>Private Key (PEM)</label><textarea id="cssl-key" class="form-control" rows="5" style="font-family:monospace;font-size:.75rem" placeholder="-----BEGIN PRIVATE KEY-----"></textarea></div>
|
||
<div class="form-group"><label>CA Chain / Bundle (optional)</label><textarea id="cssl-chain" class="form-control" rows="4" style="font-family:monospace;font-size:.75rem" placeholder="-----BEGIN CERTIFICATE----- (intermediate)"></textarea></div>`,
|
||
`<button class="btn btn-primary" onclick="adminDoInstallCustomSSL()">Install Certificate</button>`);
|
||
};
|
||
window.adminDoInstallCustomSSL = async () => {
|
||
const domain = document.getElementById('cssl-domain')?.value?.trim();
|
||
const cert = document.getElementById('cssl-cert')?.value?.trim();
|
||
const key = document.getElementById('cssl-key')?.value?.trim();
|
||
const chain = document.getElementById('cssl-chain')?.value?.trim();
|
||
if (!domain || !cert || !key) { Nova.toast('Domain, certificate, and key are required','error'); return; }
|
||
const r = await Nova.api('ssl','install-custom',{method:'POST',body:{domain,cert,key,chain}});
|
||
if (r?.success) {
|
||
Nova.toast('Custom SSL installed','success');
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
adminPage('ssl-manager');
|
||
} else { Nova.toast(r?.message||'Failed','error'); }
|
||
};
|
||
|
||
// ── Firewall ───────────────────────────────────────────────────────────────
|
||
// ── Firewall ───────────────────────────────────────────────────────────────
|
||
async function firewall() {
|
||
const [fwRes, f2bRes, ipRes, ignoreipRes] = await Promise.all([
|
||
Nova.api('firewall','status'),
|
||
Nova.api('firewall','f2b-status'),
|
||
Nova.api('firewall','ip-lists'),
|
||
Nova.api('firewall','f2b-ignoreip-list'),
|
||
]);
|
||
const fw = fwRes?.data || {};
|
||
const jails = f2bRes?.data?.jails || [];
|
||
const trusted = ipRes?.data?.trusted || [];
|
||
const blocked = ipRes?.data?.blocked || [];
|
||
const fwIgnoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || [];
|
||
const rules = fw.rules || [];
|
||
const active = fw.active;
|
||
const curLogging = fw.logging || 'off';
|
||
|
||
const totalBanned = jails.reduce((s,j) => s + (j.currently_banned||0), 0);
|
||
|
||
return `
|
||
<div class="page-header mb-3">
|
||
<h2 class="page-title">Firewall</h2>
|
||
<div style="display:flex;gap:.5rem;align-items:center">
|
||
${active ? Nova.badge('UFW Active','green') : Nova.badge('UFW Disabled','red')}
|
||
${active
|
||
? `<button class="btn btn-sm btn-ghost" onclick="fwToggle(false)">Disable UFW</button>`
|
||
: `<button class="btn btn-sm btn-primary" onclick="fwToggle(true)">Enable UFW</button>`}
|
||
<button class="btn btn-sm btn-ghost" onclick="adminPage('firewall')">
|
||
<svg class="icon-xs"><use href="/assets/img/nova-icons.svg#ni-search"/></svg> Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2 mb-3" style="gap:1.5rem">
|
||
<!-- Default Policies -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Default Policies</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="grid-2 mb-3">
|
||
<div>
|
||
<p class="text-muted text-sm">Incoming</p>
|
||
<select id="pol-incoming" class="form-control form-control-sm" style="width:auto">
|
||
${['deny','allow','reject'].map(p=>`<option value="${p}" ${fw.default_incoming===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<p class="text-muted text-sm">Outgoing</p>
|
||
<select id="pol-outgoing" class="form-control form-control-sm" style="width:auto">
|
||
${['allow','deny','reject'].map(p=>`<option value="${p}" ${fw.default_outgoing===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-sm btn-primary" onclick="fwSavePolicies()">Save Policies</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fail2Ban summary -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Fail2Ban</span>
|
||
${totalBanned > 0 ? Nova.badge(totalBanned + ' banned', 'red') : Nova.badge('0 banned','green')}
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="grid-2 mb-2">
|
||
<div><p class="text-muted text-sm">Active Jails</p><p class="font-bold">${jails.length}</p></div>
|
||
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${totalBanned}</p></div>
|
||
</div>
|
||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||
<button class="btn btn-sm btn-ghost" onclick="fwF2bReload()">Reload Config</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="fwF2bRestart()">Restart</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- UFW Rules -->
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">
|
||
<svg class="icon-sm mr-1"><use href="/assets/img/nova-icons.svg#ni-firewall"/></svg>
|
||
UFW Rules
|
||
</span>
|
||
<span class="text-muted text-sm ml-2">${rules.length} rule${rules.length!==1?'s':''}</span>
|
||
<button class="btn btn-sm btn-primary ml-auto" onclick="fwAddRuleModal()">+ Add Rule</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="fwResetModal()">Reset to Defaults</button>
|
||
</div>
|
||
${rules.length ? `
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>#</th><th>To / Port</th><th>Action</th><th>From</th><th></th></tr></thead>
|
||
<tbody>
|
||
${rules.map(r => `<tr>
|
||
<td class="text-muted text-sm">${r.num}</td>
|
||
<td><code>${Nova.escHtml(r.to)}</code></td>
|
||
<td>${fwActionBadge(r.action)}</td>
|
||
<td class="text-sm">${Nova.escHtml(r.from)}</td>
|
||
<td>
|
||
<button class="btn btn-xs btn-ghost" style="color:var(--red)" onclick="fwDeleteRule(${r.num})">Delete</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : `<div class="card-body"><p class="text-muted">No rules defined.</p></div>`}
|
||
</div>
|
||
|
||
<!-- Add Quick Rule (inline) -->
|
||
<div class="card mb-3">
|
||
<div class="card-header"><span class="card-title">Quick Rule</span></div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">Action</label>
|
||
<select id="qr-action" class="form-control form-control-sm">
|
||
<option value="allow">Allow</option>
|
||
<option value="deny">Deny</option>
|
||
<option value="reject">Reject</option>
|
||
<option value="limit">Limit</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">Direction</label>
|
||
<select id="qr-dir" class="form-control form-control-sm">
|
||
<option value="in">In</option>
|
||
<option value="out">Out</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">Port / Service</label>
|
||
<input id="qr-port" class="form-control form-control-sm" placeholder="80, 8000:9000, ssh…" style="width:160px">
|
||
</div>
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">Protocol</label>
|
||
<select id="qr-proto" class="form-control form-control-sm">
|
||
<option value="tcp">TCP</option>
|
||
<option value="udp">UDP</option>
|
||
<option value="any">Any</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">From IP (optional)</label>
|
||
<input id="qr-from" class="form-control form-control-sm" placeholder="any" style="width:150px">
|
||
</div>
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">Comment</label>
|
||
<input id="qr-comment" class="form-control form-control-sm" placeholder="optional" style="width:140px">
|
||
</div>
|
||
<button class="btn btn-primary btn-sm mb-0" onclick="fwQuickRule()">Add Rule</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2 mb-3" style="gap:1.5rem">
|
||
<!-- Trusted IPs -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Trusted IPs</span>
|
||
<span class="text-muted text-sm ml-2">${trusted.length} IPs</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
|
||
<input id="fw-trust-ip" class="form-control form-control-sm" placeholder="IP or CIDR e.g. 192.168.1.0/24" style="flex:1">
|
||
<button class="btn btn-sm btn-primary" onclick="fwAllowIp()">Allow</button>
|
||
</div>
|
||
${trusted.length ? `<div style="display:flex;flex-wrap:wrap;gap:.35rem">
|
||
${trusted.map(ip => `<span class="badge badge-green" style="cursor:pointer" onclick="fwRemoveIp('${ip}','allow')" title="Click to remove">${Nova.escHtml(ip)} ×</span>`).join('')}
|
||
</div>` : `<p class="text-muted text-sm">No trusted IPs.</p>`}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Blocked IPs -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Blocked IPs</span>
|
||
<span class="text-muted text-sm ml-2">${blocked.length} IPs</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
|
||
<input id="fw-block-ip" class="form-control form-control-sm" placeholder="IP or CIDR e.g. 1.2.3.4" style="flex:1">
|
||
<button class="btn btn-sm" style="background:var(--red);color:#fff" onclick="fwBlockIp()">Block</button>
|
||
</div>
|
||
${blocked.length ? `<div style="display:flex;flex-wrap:wrap;gap:.35rem">
|
||
${blocked.map(ip => `<span class="badge badge-red" style="cursor:pointer" onclick="fwRemoveIp('${ip}','deny')" title="Click to remove">${Nova.escHtml(ip)} ×</span>`).join('')}
|
||
</div>` : `<p class="text-muted text-sm">No blocked IPs.</p>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fail2Ban Jails -->
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">Fail2Ban Jails</span>
|
||
</div>
|
||
${jails.length ? `
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Jail</th><th>Currently Banned</th><th>Total Banned</th><th>Failed</th><th>Actions</th></tr></thead>
|
||
<tbody>
|
||
${jails.map(j => `<tr>
|
||
<td><strong>${Nova.escHtml(j.jail)}</strong></td>
|
||
<td>${j.currently_banned > 0 ? `<span style="color:var(--red);font-weight:600">${j.currently_banned}</span>` : '0'}</td>
|
||
<td class="text-muted">${j.total_banned}</td>
|
||
<td class="text-muted">${j.currently_failed}</td>
|
||
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
|
||
${j.currently_banned > 0 ? `<button class="btn btn-xs btn-ghost" onclick="fwJailDetail('${j.jail}')">View Banned</button>` : ''}
|
||
<button class="btn btn-xs btn-ghost" onclick="fwManualBanModal('${j.jail}')">Ban IP</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : `<div class="card-body"><p class="text-muted">Fail2Ban not running or no jails configured.</p></div>`}
|
||
</div>
|
||
|
||
<!-- Fail2Ban Whitelist (ignoreip) -->
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">Fail2Ban Whitelist</span>
|
||
<span class="text-muted text-sm ml-2">IPs that will <strong>never</strong> be banned</span>
|
||
<button class="btn btn-xs btn-ghost ml-auto" onclick="fwIgnoreipReset()" title="Reset to server defaults">Reset to Defaults</button>
|
||
</div>
|
||
<div class="card-body" id="ignoreip-body">
|
||
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
|
||
<input id="fw-ignoreip-input" class="form-control form-control-sm"
|
||
placeholder="IP or CIDR — e.g. 192.168.1.50 or 10.0.0.0/8" style="flex:1">
|
||
<button class="btn btn-sm btn-primary" onclick="fwIgnoreipAdd()">Add to Whitelist</button>
|
||
</div>
|
||
<div id="ignoreip-chips" style="display:flex;flex-wrap:wrap;gap:.35rem">
|
||
${(fwIgnoreips||[]).map(ip => fwIgnoreipChip(ip)).join('')}
|
||
</div>
|
||
<p class="text-muted text-sm mt-2" style="font-size:.75rem">
|
||
Loopback (127.0.0.0/8, ::1) and the server's own LAN IPs are added automatically.
|
||
Add your home/office IP or subnet here so you never lock yourself out.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- UFW Logging -->
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">UFW Logging</span></div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap">
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">Log Level</label>
|
||
<select id="fw-log-level" class="form-control form-control-sm">
|
||
${['off','on','low','medium','high','full'].map(l=>`<option value="${l}" ${l===curLogging?'selected':''}>${l.charAt(0).toUpperCase()+l.slice(1)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-sm btn-primary" onclick="fwSetLogging()">Apply</button>
|
||
<span class="text-muted text-sm">Logs at <code>/var/log/ufw.log</code></span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Fail2Ban Manager ────────────────────────────────────────────────────────
|
||
async function fail2ban() {
|
||
const [f2bRes, cfgRes, ignoreipRes] = await Promise.all([
|
||
Nova.api('firewall', 'f2b-status'),
|
||
Nova.api('firewall', 'f2b-config-get'),
|
||
Nova.api('firewall', 'f2b-ignoreip-list'),
|
||
]);
|
||
const jails = f2bRes?.data?.jails || [];
|
||
const cfg = cfgRes?.data || { bantime: 3600, findtime: 600, maxretry: 5 };
|
||
const ignoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || [];
|
||
const totalBanned = jails.reduce((s, j) => s + (j.currently_banned || 0), 0);
|
||
|
||
return `
|
||
<div class="page-header mb-3">
|
||
<h2 class="page-title">Fail2Ban</h2>
|
||
<div style="display:flex;gap:.5rem;align-items:center">
|
||
${totalBanned > 0 ? Nova.badge(totalBanned + ' banned', 'red') : Nova.badge('Clean', 'green')}
|
||
<button class="btn btn-sm btn-ghost" onclick="adminPage('fail2ban')">↻ Refresh</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="f2bReloadCfg()">Reload Config</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="f2bRestartSvc()">Restart Service</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Global Settings -->
|
||
<div class="card mb-3">
|
||
<div class="card-header"><span class="card-title">Global Settings</span></div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-end">
|
||
<div class="form-group mb-0">
|
||
<label class="form-label">Ban Time (seconds)</label>
|
||
<input id="f2b-bantime" type="number" class="form-control" value="${Nova.escHtml(cfg.bantime)}" style="width:130px" min="60">
|
||
<small class="text-muted">How long IPs stay banned</small>
|
||
</div>
|
||
<div class="form-group mb-0">
|
||
<label class="form-label">Find Time (seconds)</label>
|
||
<input id="f2b-findtime" type="number" class="form-control" value="${Nova.escHtml(cfg.findtime)}" style="width:130px" min="60">
|
||
<small class="text-muted">Window to count failures</small>
|
||
</div>
|
||
<div class="form-group mb-0">
|
||
<label class="form-label">Max Retry</label>
|
||
<input id="f2b-maxretry" type="number" class="form-control" value="${Nova.escHtml(cfg.maxretry)}" style="width:100px" min="1">
|
||
<small class="text-muted">Failures before ban</small>
|
||
</div>
|
||
<button class="btn btn-primary mb-0" onclick="f2bSaveCfg()">Save Settings</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Jails -->
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">Active Jails</span>
|
||
<span class="text-muted text-sm ml-2">${jails.length} jail${jails.length !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
${jails.length ? `
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Jail</th><th>Currently Banned</th><th>Total Banned</th><th>Failed</th><th></th></tr></thead>
|
||
<tbody>
|
||
${jails.map(j => `<tr>
|
||
<td><strong>${Nova.escHtml(j.jail)}</strong></td>
|
||
<td>${j.currently_banned > 0
|
||
? `<span style="color:var(--red);font-weight:600">${j.currently_banned}</span>`
|
||
: '<span class="text-muted">0</span>'}</td>
|
||
<td class="text-muted">${j.total_banned}</td>
|
||
<td class="text-muted">${j.currently_failed}</td>
|
||
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
|
||
${j.currently_banned > 0
|
||
? `<button class="btn btn-xs btn-ghost" onclick="f2bViewJail('${Nova.escHtml(j.jail)}')">View Banned</button>`
|
||
: ''}
|
||
<button class="btn btn-xs btn-ghost" onclick="f2bBanModal('${Nova.escHtml(j.jail)}')">Ban IP</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : `<div class="card-body"><p class="text-muted">Fail2Ban not running or no jails active.</p></div>`}
|
||
</div>
|
||
|
||
<!-- Whitelist -->
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">Whitelist (Never Ban)</span>
|
||
<span class="text-muted text-sm ml-2">${ignoreips.length} entr${ignoreips.length !== 1 ? 'ies' : 'y'}</span>
|
||
<button class="btn btn-xs btn-ghost ml-auto" onclick="fwIgnoreipReset()">Reset to Defaults</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
|
||
<input id="f2b-ignoreip-input" class="form-control form-control-sm"
|
||
placeholder="IP or CIDR — e.g. 203.0.113.5 or 192.168.1.0/24" style="flex:1">
|
||
<button class="btn btn-sm btn-primary" onclick="f2bWhitelistAdd()">Add</button>
|
||
</div>
|
||
<div id="f2b-ignoreip-chips" style="display:flex;flex-wrap:wrap;gap:.35rem">
|
||
${ignoreips.map(ip => `<span class="badge badge-green" style="cursor:pointer" title="Click to remove"
|
||
onclick="f2bWhitelistRemove('${Nova.escHtml(ip)}')">${Nova.escHtml(ip)} ×</span>`).join('')}
|
||
</div>
|
||
<p class="text-muted text-sm mt-2" style="font-size:.75rem">Your own IP/subnet should always be whitelisted.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Log Viewer -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Log Viewer</span>
|
||
<div style="display:flex;gap:.5rem;align-items:center;margin-left:auto">
|
||
<select id="f2b-log-lines" class="form-control form-control-sm" style="width:auto">
|
||
<option value="50">Last 50</option>
|
||
<option value="100" selected>Last 100</option>
|
||
<option value="250">Last 250</option>
|
||
<option value="500">Last 500</option>
|
||
</select>
|
||
<button class="btn btn-sm btn-ghost" onclick="f2bLoadLog()">Load Log</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div id="f2b-log-content" style="background:#0d1117;color:#c9d1d9;font-family:monospace;font-size:.75rem;padding:1rem;max-height:400px;overflow-y:auto;border-radius:0 0 8px 8px">
|
||
<span class="text-muted">Click "Load Log" to view Fail2Ban activity.</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
window.f2bSaveCfg = async () => {
|
||
const bantime = document.getElementById('f2b-bantime')?.value;
|
||
const findtime = document.getElementById('f2b-findtime')?.value;
|
||
const maxretry = document.getElementById('f2b-maxretry')?.value;
|
||
const r = await Nova.api('firewall', 'f2b-config-save', { method: 'POST', body: { bantime, findtime, maxretry } });
|
||
Nova.toast(r?.message || (r?.success ? 'Saved' : 'Failed'), r?.success ? 'success' : 'error');
|
||
};
|
||
|
||
window.f2bReloadCfg = async () => {
|
||
const r = await Nova.api('firewall', 'f2b-reload', { method: 'POST' });
|
||
Nova.toast(r?.message || (r?.success ? 'Reloaded' : 'Failed'), r?.success ? 'success' : 'error');
|
||
};
|
||
|
||
window.f2bRestartSvc = async () => {
|
||
const r = await Nova.api('firewall', 'f2b-restart', { method: 'POST' });
|
||
Nova.toast(r?.message || (r?.success ? 'Restarted' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('fail2ban');
|
||
};
|
||
|
||
window.f2bViewJail = async (jail) => {
|
||
const r = await Nova.api('firewall', 'f2b-jail', { method: 'POST', body: { jail } });
|
||
const d = r?.data || {};
|
||
const ips = d.banned_ips || [];
|
||
Nova.modal(`Jail: ${jail}`,
|
||
`<div style="display:flex;gap:2rem;margin-bottom:1rem">
|
||
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${d.currently_banned ?? 0}</p></div>
|
||
<div><p class="text-muted text-sm">Total Banned</p><p class="font-bold">${d.total_banned ?? 0}</p></div>
|
||
<div><p class="text-muted text-sm">Currently Failed</p><p class="font-bold">${d.currently_failed ?? 0}</p></div>
|
||
</div>
|
||
${ips.length ? `<table style="width:100%"><thead><tr><th>IP Address</th><th></th></tr></thead><tbody>
|
||
${ips.map(ip => `<tr>
|
||
<td><code>${Nova.escHtml(ip)}</code></td>
|
||
<td><button class="btn btn-xs" style="color:var(--red)" onclick="f2bUnban('${Nova.escHtml(ip)}','${Nova.escHtml(jail)}')">Unban</button></td>
|
||
</tr>`).join('')}
|
||
</tbody></table>` : '<p class="text-muted">No IPs currently banned.</p>'}`
|
||
);
|
||
};
|
||
|
||
window.f2bBanModal = (jail) => {
|
||
Nova.modal(`Ban IP in jail: ${jail}`,
|
||
`<div class="form-group">
|
||
<label class="form-label">IP Address to Ban</label>
|
||
<input id="f2b-ban-ip" class="form-control" placeholder="1.2.3.4">
|
||
</div>`,
|
||
`<button class="btn btn-primary" onclick="f2bBanSubmit('${Nova.escHtml(jail)}')">Ban IP</button>`
|
||
);
|
||
};
|
||
|
||
window.f2bBanSubmit = async (jail) => {
|
||
const ip = document.getElementById('f2b-ban-ip')?.value?.trim();
|
||
if (!ip) return;
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
const r = await Nova.api('firewall', 'f2b-ban', { method: 'POST', body: { ip, jail } });
|
||
Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('fail2ban');
|
||
};
|
||
|
||
window.f2bUnban = async (ip, jail) => {
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
const r = await Nova.api('firewall', 'f2b-unban', { method: 'POST', body: { ip, jail } });
|
||
Nova.toast(r?.message || (r?.success ? 'Unbanned' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('fail2ban');
|
||
};
|
||
|
||
window.f2bWhitelistAdd = async () => {
|
||
const ip = document.getElementById('f2b-ignoreip-input')?.value?.trim();
|
||
if (!ip) return;
|
||
const r = await Nova.api('firewall', 'f2b-ignoreip-add', { method: 'POST', body: { ip } });
|
||
Nova.toast(r?.message || (r?.success ? 'Added' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('fail2ban');
|
||
};
|
||
|
||
window.f2bWhitelistRemove = async (ip) => {
|
||
const r = await Nova.api('firewall', 'f2b-ignoreip-remove', { method: 'POST', body: { ip } });
|
||
Nova.toast(r?.message || (r?.success ? 'Removed' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('fail2ban');
|
||
};
|
||
|
||
window.f2bLoadLog = async () => {
|
||
const lines = document.getElementById('f2b-log-lines')?.value || 100;
|
||
const el = document.getElementById('f2b-log-content');
|
||
if (el) el.innerHTML = '<span class="text-muted">Loading…</span>';
|
||
const r = await Nova.api('firewall', 'f2b-log', { method: 'POST', body: { lines: parseInt(lines) } });
|
||
if (el) {
|
||
if (r?.success && r.data?.log) {
|
||
// Colorize: NOTICE=green, WARNING=yellow, ERROR/BAN=red, UNBAN=blue
|
||
const colored = Nova.escHtml(r.data.log)
|
||
.replace(/(NOTICE)/g, '<span style="color:#58a6ff">$1</span>')
|
||
.replace(/(WARNING)/g, '<span style="color:#e3b341">$1</span>')
|
||
.replace(/\b(BAN)\b/g, '<span style="color:#f85149">$1</span>')
|
||
.replace(/\b(UNBAN)\b/g, '<span style="color:#3fb950">$1</span>')
|
||
.replace(/(ERROR)/g, '<span style="color:#f85149;font-weight:bold">$1</span>');
|
||
el.innerHTML = colored;
|
||
el.scrollTop = el.scrollHeight;
|
||
} else {
|
||
el.innerHTML = '<span style="color:var(--red)">Failed to load log.</span>';
|
||
}
|
||
}
|
||
};
|
||
|
||
function fwActionBadge(action) {
|
||
const a = (action||'').toLowerCase();
|
||
if (a.includes('allow')) return Nova.badge('ALLOW','green');
|
||
if (a.includes('deny')) return Nova.badge('DENY','red');
|
||
if (a.includes('reject'))return Nova.badge('REJECT','red');
|
||
if (a.includes('limit')) return Nova.badge('LIMIT','yellow');
|
||
return `<span class="text-sm">${Nova.escHtml(action)}</span>`;
|
||
}
|
||
|
||
window.fwToggle = async (enable) => {
|
||
const label = enable ? 'Enable' : 'Disable';
|
||
Nova.confirm(`${label} UFW firewall?`, async () => {
|
||
const r = await Nova.api('firewall', enable ? 'enable' : 'disable', {method:'POST'});
|
||
Nova.toast(r?.message || label + 'd', r?.success ? 'success' : 'error');
|
||
adminPage('firewall');
|
||
}, !enable);
|
||
};
|
||
|
||
window.fwSavePolicies = async () => {
|
||
const inc = document.getElementById('pol-incoming')?.value;
|
||
const out = document.getElementById('pol-outgoing')?.value;
|
||
await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'incoming',policy:inc}});
|
||
const r2 = await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'outgoing',policy:out}});
|
||
Nova.toast(r2?.success ? 'Policies saved' : r2?.message, r2?.success ? 'success' : 'error');
|
||
adminPage('firewall');
|
||
};
|
||
|
||
window.fwDeleteRule = (num) => {
|
||
Nova.confirm(`Delete rule #${num}? This cannot be undone.`, async () => {
|
||
const r = await Nova.api('firewall','delete-rule',{method:'POST',body:{num}});
|
||
Nova.toast(r?.message || 'Deleted', r?.success ? 'success' : 'error');
|
||
adminPage('firewall');
|
||
}, true);
|
||
};
|
||
|
||
window.fwResetModal = () => {
|
||
Nova.confirm('Reset ALL firewall rules to NovaCPX defaults? SSH, HTTP, HTTPS, and panel ports will be re-allowed automatically.', async () => {
|
||
Nova.toast('Resetting firewall…','info',5000);
|
||
const r = await Nova.api('firewall','reset',{method:'POST'});
|
||
Nova.toast(r?.message || 'Reset complete','success');
|
||
adminPage('firewall');
|
||
}, true);
|
||
};
|
||
|
||
window.fwQuickRule = async () => {
|
||
const body = {
|
||
action: document.getElementById('qr-action')?.value,
|
||
direction: document.getElementById('qr-dir')?.value,
|
||
port: document.getElementById('qr-port')?.value,
|
||
proto: document.getElementById('qr-proto')?.value,
|
||
from_ip: document.getElementById('qr-from')?.value || 'any',
|
||
comment: document.getElementById('qr-comment')?.value,
|
||
};
|
||
if (!body.port) { Nova.toast('Port/service is required','error'); return; }
|
||
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
|
||
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('firewall');
|
||
};
|
||
|
||
window.fwAddRuleModal = () => {
|
||
Nova.modal('Add Firewall Rule',`
|
||
<div class="form-group"><label>Action</label>
|
||
<select id="m-action" class="form-control">
|
||
<option value="allow">Allow</option><option value="deny">Deny</option>
|
||
<option value="reject">Reject</option><option value="limit">Limit (rate-limit)</option>
|
||
</select></div>
|
||
<div class="form-group"><label>Direction</label>
|
||
<select id="m-dir" class="form-control">
|
||
<option value="in">Incoming</option><option value="out">Outgoing</option>
|
||
</select></div>
|
||
<div class="form-group"><label>Port / Service</label>
|
||
<input id="m-port" class="form-control" placeholder="e.g. 443, 8000:9000, ssh, smtp"></div>
|
||
<div class="form-group"><label>Protocol</label>
|
||
<select id="m-proto" class="form-control">
|
||
<option value="tcp">TCP</option><option value="udp">UDP</option><option value="any">Any</option>
|
||
</select></div>
|
||
<div class="form-group"><label>From IP / CIDR (leave blank for any)</label>
|
||
<input id="m-from" class="form-control" placeholder="any or 192.168.1.0/24"></div>
|
||
<div class="form-group"><label>To IP / CIDR (leave blank for any)</label>
|
||
<input id="m-to" class="form-control" placeholder="any"></div>
|
||
<div class="form-group"><label>Comment</label>
|
||
<input id="m-comment" class="form-control" placeholder="optional description"></div>
|
||
`,`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="fwSubmitAddRule()">Add Rule</button>`);
|
||
};
|
||
|
||
window.fwSubmitAddRule = async () => {
|
||
const body = {
|
||
action: document.getElementById('m-action')?.value,
|
||
direction: document.getElementById('m-dir')?.value,
|
||
port: document.getElementById('m-port')?.value,
|
||
proto: document.getElementById('m-proto')?.value,
|
||
from_ip: document.getElementById('m-from')?.value || 'any',
|
||
to_ip: document.getElementById('m-to')?.value || 'any',
|
||
comment: document.getElementById('m-comment')?.value,
|
||
};
|
||
if (!body.port) { Nova.toast('Port is required','error'); return; }
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
|
||
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('firewall');
|
||
};
|
||
|
||
window.fwAllowIp = async () => {
|
||
const ip = document.getElementById('fw-trust-ip')?.value?.trim();
|
||
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
|
||
const r = await Nova.api('firewall','allow-ip',{method:'POST',body:{ip}});
|
||
Nova.toast(r?.message || (r?.success ? 'IP allowed' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('firewall');
|
||
};
|
||
|
||
window.fwBlockIp = async () => {
|
||
const ip = document.getElementById('fw-block-ip')?.value?.trim();
|
||
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
|
||
Nova.confirm(`Block ${ip}? This will deny all incoming traffic from this address.`, async () => {
|
||
const r = await Nova.api('firewall','block-ip',{method:'POST',body:{ip}});
|
||
Nova.toast(r?.message || (r?.success ? 'IP blocked' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('firewall');
|
||
}, true);
|
||
};
|
||
|
||
window.fwRemoveIp = (ip, action) => {
|
||
Nova.confirm(`Remove ${action} rule for ${ip}?`, async () => {
|
||
const r = await Nova.api('firewall','remove-ip',{method:'POST',body:{ip,action}});
|
||
Nova.toast(r?.message || 'Removed', r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('firewall');
|
||
}, true);
|
||
};
|
||
|
||
window.fwJailDetail = async (jail) => {
|
||
const r = await Nova.api('firewall','f2b-jail',{method:'POST',body:{jail}});
|
||
const d = r?.data || {};
|
||
const ips = d.banned_ips || [];
|
||
Nova.modal(`Fail2Ban: ${jail}`,`
|
||
<div class="grid-2 mb-2" style="gap:.75rem">
|
||
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${d.currently_banned}</p></div>
|
||
<div><p class="text-muted text-sm">Total Banned</p><p class="font-bold">${d.total_banned}</p></div>
|
||
</div>
|
||
${ips.length ? `
|
||
<table style="width:100%;font-size:.85rem">
|
||
<thead><tr><th style="text-align:left;padding:.35rem .5rem">Banned IP</th><th></th></tr></thead>
|
||
<tbody>
|
||
${ips.map(ip=>`<tr>
|
||
<td style="padding:.3rem .5rem"><code>${Nova.escHtml(ip)}</code></td>
|
||
<td style="padding:.3rem .5rem;text-align:right">
|
||
<button class="btn btn-xs btn-primary" onclick="fwUnbanIp('${Nova.escHtml(ip)}','${jail}',this)">Unban</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>` : '<p class="text-muted">No IPs currently banned in this jail.</p>'}`);
|
||
};
|
||
|
||
window.fwUnbanIp = async (ip, jail, btn) => {
|
||
if (btn) btn.disabled = true;
|
||
const r = await Nova.api('firewall','f2b-unban',{method:'POST',body:{ip,jail}});
|
||
Nova.toast(r?.message || 'Unbanned', r?.success ? 'success' : 'error');
|
||
if (r?.success && btn) btn.closest('tr')?.remove();
|
||
};
|
||
|
||
window.fwManualBanModal = (jail) => {
|
||
Nova.modal(`Manual Ban — ${jail}`,`
|
||
<div class="form-group">
|
||
<label>IP Address to Ban</label>
|
||
<input id="mb-ip" class="form-control" placeholder="1.2.3.4" autofocus>
|
||
</div>`,`
|
||
<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn" style="background:var(--red);color:#fff" onclick="fwSubmitManualBan('${jail}')">Ban IP</button>`);
|
||
};
|
||
|
||
window.fwSubmitManualBan = async (jail) => {
|
||
const ip = document.getElementById('mb-ip')?.value?.trim();
|
||
if (!ip) { Nova.toast('Enter an IP','error'); return; }
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
const r = await Nova.api('firewall','f2b-ban',{method:'POST',body:{ip,jail}});
|
||
Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('firewall');
|
||
};
|
||
|
||
window.fwF2bReload = async () => {
|
||
const r = await Nova.api('firewall','f2b-reload',{method:'POST'});
|
||
Nova.toast(r?.message || 'Reloaded', r?.success ? 'success' : 'error');
|
||
};
|
||
|
||
window.fwF2bRestart = async () => {
|
||
Nova.confirm('Restart Fail2Ban? Active bans will be preserved.', async () => {
|
||
const r = await Nova.api('firewall','f2b-restart',{method:'POST'});
|
||
Nova.toast(r?.message || 'Restarted', r?.success ? 'success' : 'error');
|
||
adminPage('firewall');
|
||
});
|
||
};
|
||
|
||
window.fwSetLogging = async () => {
|
||
const level = document.getElementById('fw-log-level')?.value;
|
||
const r = await Nova.api('firewall','set-logging',{method:'POST',body:{level}});
|
||
if (r?.success) {
|
||
Nova.toast(`UFW logging set to ${level}`, 'success');
|
||
adminPage('firewall');
|
||
} else {
|
||
Nova.toast(r?.message || 'Logging update failed — UFW may need to be enabled first', 'error');
|
||
}
|
||
};
|
||
|
||
function fwIgnoreipChip(ip) {
|
||
const isLoopback = ip === '127.0.0.0/8' || ip === '127.0.0.1' || ip === '::1';
|
||
return `<span class="badge ${isLoopback ? 'badge-blue' : 'badge-green'}" style="cursor:${isLoopback?'default':'pointer'}"
|
||
${isLoopback ? '' : `onclick="fwIgnoreipRemove('${Nova.escHtml(ip)}')" title="Click to remove"`}>
|
||
${Nova.escHtml(ip)}${isLoopback ? ' 🔒' : ' ×'}
|
||
</span>`;
|
||
}
|
||
|
||
window.fwIgnoreipAdd = async () => {
|
||
const ip = document.getElementById('fw-ignoreip-input')?.value?.trim();
|
||
if (!ip) { Nova.toast('Enter an IP address or CIDR range', 'error'); return; }
|
||
const r = await Nova.api('firewall','f2b-ignoreip-add',{method:'POST',body:{ip}});
|
||
Nova.toast(r?.message || (r?.success ? 'Added' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) {
|
||
const chips = document.getElementById('ignoreip-chips');
|
||
if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join('');
|
||
const inp = document.getElementById('fw-ignoreip-input');
|
||
if (inp) inp.value = '';
|
||
}
|
||
};
|
||
|
||
window.fwIgnoreipRemove = async (ip) => {
|
||
Nova.confirm(`Remove ${ip} from Fail2Ban whitelist? They could get banned if they fail too many login attempts.`, async () => {
|
||
const r = await Nova.api('firewall','f2b-ignoreip-remove',{method:'POST',body:{ip}});
|
||
Nova.toast(r?.message || (r?.success ? 'Removed' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) {
|
||
const chips = document.getElementById('ignoreip-chips');
|
||
if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join('');
|
||
}
|
||
}, true);
|
||
};
|
||
|
||
window.fwIgnoreipReset = () => {
|
||
Nova.confirm('Reset Fail2Ban whitelist to server defaults (loopback + local IPs)?', async () => {
|
||
const r = await Nova.api('firewall','f2b-ignoreip-reset',{method:'POST'});
|
||
Nova.toast(r?.message || 'Reset', r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('firewall');
|
||
});
|
||
};
|
||
|
||
// ── MySQL/DB Manager ───────────────────────────────────────────────────────
|
||
async function mysqlManager() {
|
||
const [engRes, dbRes, toolsRes] = await Promise.all([
|
||
Nova.api('system','db-engines'),
|
||
Nova.api('databases','list',{params:{account_id:0}}),
|
||
Nova.api('system','db-tools'),
|
||
]);
|
||
const eng = engRes?.data?.engines || {};
|
||
const actE = engRes?.data?.active_engine || 'mysql';
|
||
const dbs = dbRes?.data || [];
|
||
const tools = toolsRes?.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 `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<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">
|
||
${!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>
|
||
</div>
|
||
</div>`;
|
||
};
|
||
|
||
const toolCard = (id, label, icon, url) => {
|
||
const t = tools[id] || {};
|
||
const statusColor = t.installed ? 'green' : 'default';
|
||
const statusText = t.installed ? 'Installed' : 'Not Installed';
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">${icon} ${label}</span>
|
||
${Nova.badge(statusText, statusColor)}
|
||
${t.version ? `<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">v${t.version}</span>` : ''}
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
|
||
${!t.installed
|
||
? `<button class="btn btn-xs btn-primary" onclick="dbToolAction('${id}','install')">Install</button>`
|
||
: `
|
||
<button class="btn btn-xs" onclick="dbToolAction('${id}','reinstall')">Reinstall</button>
|
||
<button class="btn btn-xs btn-danger" onclick="dbToolAction('${id}','remove')">Remove</button>
|
||
<a href="${url}" target="_blank" class="btn btn-xs btn-ghost">Open ↗</a>`
|
||
}
|
||
</div>
|
||
</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>
|
||
<td><strong>${Nova.escHtml(d.db_name)}</strong></td>
|
||
<td>${Nova.escHtml(d.db_user||'—')}</td>
|
||
<td>${Nova.badge(d.db_type||'mysql','default')}</td>
|
||
<td>${Nova.escHtml(d.username||'—')}</td>
|
||
<td><button class="btn btn-xs btn-danger" onclick="adminDropDB(${d.id},'${Nova.escHtml(d.db_name)}')">Drop</button></td>
|
||
</tr>`).join('')}
|
||
</tbody></table>` : '<div class="empty" style="padding:2rem">No databases yet.</div>';
|
||
|
||
return `
|
||
<div class="page-header mb-2"><h2 class="page-title">Database Manager</h2></div>
|
||
<div style="display:flex;gap:.5rem;margin-bottom:1.25rem;flex-wrap:wrap">
|
||
<a href="/phpmyadmin" target="_blank" class="btn btn-sm">🐬 phpMyAdmin (MySQL)</a>
|
||
<a href="/adminer.php" target="_blank" class="btn btn-sm">🗄️ Adminer (MySQL)</a>
|
||
<a href="/adminer.php?pgsql=" target="_blank" class="btn btn-sm">🐘 Adminer (PostgreSQL)</a>
|
||
</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" style="margin-bottom:1.5rem">
|
||
<div class="card-header"><span class="card-title">Database Admin Tools</span></div>
|
||
<div class="card-body" style="padding-bottom:.5rem">
|
||
<div class="grid-2 gap-2">
|
||
${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)}
|
||
${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)}
|
||
${toolCard('adminer', 'Adminer', '🗄️', `http://${location.hostname}/adminer.php`)}
|
||
</div>
|
||
</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>`;
|
||
}
|
||
|
||
window.adminDropDB = (id, name) => {
|
||
Nova.confirm(`Drop database ${name}? ALL DATA WILL BE LOST.`, async () => {
|
||
const r = await Nova.api('databases','drop',{method:'POST',body:{id}});
|
||
if (r?.success) { Nova.toast('Dropped','success'); adminPage('mysql-manager'); }
|
||
else Nova.toast(r?.message,'error');
|
||
}, true);
|
||
};
|
||
window.dbEngineAction = (engine, action) => {
|
||
const labels = {install:`Installing ${engine}…`,remove:`Removing ${engine}…`,start:`Starting ${engine}…`,stop:`Stopping ${engine}…`,restart:`Restarting ${engine}…`};
|
||
const doIt = async () => {
|
||
Nova.loading(labels[action] || `Working on ${engine}…`);
|
||
const r = await Nova.api('system','db-engine-action',{method:'POST',body:{engine,action}});
|
||
Nova.loadingDone();
|
||
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');
|
||
};
|
||
|
||
window.dbToolAction = (tool, action) => {
|
||
const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' };
|
||
const name = names[tool] || tool;
|
||
const msgs = {
|
||
install: `Install ${name}?`,
|
||
reinstall: `Reinstall ${name}? The existing installation will be removed first.`,
|
||
remove: `Remove ${name}?`,
|
||
};
|
||
|
||
// pgAdmin needs an admin account — collect credentials before install/reinstall
|
||
if (tool === 'pgadmin' && action !== 'remove') {
|
||
Nova.modal(`${action === 'reinstall' ? 'Reinstall' : 'Install'} pgAdmin 4`, `
|
||
<p class="text-muted text-sm mb-2">pgAdmin requires an admin account to be created on first run.</p>
|
||
<div class="form-group"><label>Admin Email</label><input id="pga-email" class="form-control" type="email" placeholder="admin@example.com"></div>
|
||
<div class="form-group"><label>Admin Password</label><input id="pga-pass" class="form-control" type="password" placeholder="Strong password"></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="dbToolRunInstall('pgadmin','${action}')">Continue</button>`);
|
||
return;
|
||
}
|
||
|
||
const openTerminal = (extra = {}) => {
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
const termId = 'dbt-term-' + Date.now();
|
||
const verb = action === 'remove' ? 'Removing' : action === 'reinstall' ? 'Reinstalling' : 'Installing';
|
||
Nova.modal(`${verb} ${name}`, `
|
||
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
|
||
padding:1rem;border-radius:6px;height:280px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||
<span style="color:#7ec8e3">Starting…</span>\n
|
||
</div>`,
|
||
`<button class="btn btn-ghost" id="dbt-term-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
|
||
|
||
const term = document.getElementById(termId);
|
||
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
|
||
|
||
fetch('/api/system/db-tools-stream', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tool, action, ...extra }),
|
||
credentials: 'same-origin',
|
||
}).then(resp => {
|
||
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
const read = () => reader.read().then(({ done, value }) => {
|
||
if (done) { append('\n[stream closed]'); return; }
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const obj = JSON.parse(m[1]);
|
||
if (obj.line) { append(obj.line); }
|
||
else if (obj.error) { append(`\n✗ ${obj.error}\n`); }
|
||
else if (obj.done) {
|
||
const btn = document.getElementById('dbt-term-close');
|
||
if (btn) {
|
||
btn.textContent = 'Done';
|
||
btn.className = 'btn btn-primary';
|
||
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); };
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
read();
|
||
}).catch(err => append(`\n[error: ${err.message}]`));
|
||
read();
|
||
}).catch(err => append(`\nFetch error: ${err.message}`));
|
||
};
|
||
|
||
Nova.confirm(msgs[action], () => openTerminal(), action === 'remove');
|
||
};
|
||
|
||
window.dbToolRunInstall = (tool, action) => {
|
||
const email = document.getElementById('pga-email')?.value?.trim();
|
||
const pass = document.getElementById('pga-pass')?.value;
|
||
if (!email) { Nova.toast('Email is required','error'); return; }
|
||
if (!pass) { Nova.toast('Password is required','error'); return; }
|
||
// Re-invoke with credentials — openTerminal will close the form modal first
|
||
const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' };
|
||
const name = names[tool] || tool;
|
||
const msgs = { install: `Install ${name}?`, reinstall: `Reinstall ${name}?` };
|
||
const doOpen = () => {
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
const termId = 'dbt-term-' + Date.now();
|
||
const verb = action === 'reinstall' ? 'Reinstalling' : 'Installing';
|
||
Nova.modal(`${verb} ${name}`, `
|
||
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
|
||
padding:1rem;border-radius:6px;height:280px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||
<span style="color:#7ec8e3">Starting…</span>\n
|
||
</div>`,
|
||
`<button class="btn btn-ghost" id="dbt-term-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
|
||
const term = document.getElementById(termId);
|
||
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
|
||
fetch('/api/system/db-tools-stream', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tool, action, pga_email: email, pga_pass: pass }),
|
||
credentials: 'same-origin',
|
||
}).then(resp => {
|
||
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
const read = () => reader.read().then(({ done, value }) => {
|
||
if (done) { append('\n[stream closed]'); return; }
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const obj = JSON.parse(m[1]);
|
||
if (obj.line) { append(obj.line); }
|
||
else if (obj.error) { append(`\n✗ ${obj.error}\n`); }
|
||
else if (obj.done) {
|
||
const btn = document.getElementById('dbt-term-close');
|
||
if (btn) {
|
||
btn.textContent = 'Done';
|
||
btn.className = 'btn btn-primary';
|
||
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); };
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
read();
|
||
}).catch(err => append(`\n[error: ${err.message}]`));
|
||
read();
|
||
}).catch(err => append(`\nFetch error: ${err.message}`));
|
||
};
|
||
doOpen();
|
||
};
|
||
|
||
// ── Mail Server ────────────────────────────────────────────────────────────
|
||
async function mailServer() {
|
||
const [statsR, domainsR] = await Promise.all([
|
||
Nova.api('system','stats'),
|
||
Nova.api('email','domains'),
|
||
]);
|
||
const svcs = statsR?.data?.services || {};
|
||
const mailSvcs = ['postfix','dovecot','rspamd','opendkim'].filter(s => svcs[s]);
|
||
const domains = domainsR?.data || [];
|
||
|
||
window.msLoadLog = async () => {
|
||
const r = await Nova.api('system','read-log',{params:{log:'mail'}});
|
||
const el = document.getElementById('ms-log-out');
|
||
if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; }
|
||
};
|
||
|
||
return `
|
||
<div class="page-header mb-2"><h2 class="page-title">Mail Server</h2></div>
|
||
<div class="grid-2 mb-3">
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">Services</span></div>
|
||
<div class="card-body">
|
||
${mailSvcs.length ? mailSvcs.map(s => `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid var(--border)">
|
||
<span style="font-weight:500">${s} ${Nova.badge(svcs[s],svcs[s]==='active'?'green':'red')}</span>
|
||
<div style="display:flex;gap:.35rem">
|
||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">↺ Restart</button>
|
||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')">⟳ Reload</button>
|
||
</div>
|
||
</div>`).join('') : '<p class="text-muted">No mail services detected</p>'}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Mail Queue</span>
|
||
<button class="btn btn-xs btn-warning" style="margin-left:auto" onclick="Nova.confirm('Flush all queued mail?',()=>adminServiceAction('postfix','flush'))">Flush</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<button class="btn btn-sm" onclick="adminViewMailQueue()">View Queue</button>
|
||
<div id="ms-queue-out" style="margin-top:.75rem;font-size:.82rem;color:var(--text-muted)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">Virtual Mail Domains (${domains.length})</span>
|
||
</div>
|
||
${domains.length ? `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Email Accounts</th></tr></thead><tbody>
|
||
${domains.map(d=>`<tr><td><strong>${Nova.escHtml(d.domain)}</strong></td><td>${Nova.escHtml(d.username||'—')}</td><td>${d.email_count||0}</td></tr>`).join('')}
|
||
</tbody></table></div>` : '<div class="card-body text-muted">No mail domains yet — created automatically when hosting accounts are set up.</div>'}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Mail Log</span>
|
||
<button class="btn btn-xs" style="margin-left:auto" onclick="msLoadLog()">Load Log</button>
|
||
</div>
|
||
<pre id="ms-log-out" style="background:var(--bg);padding:1rem;font-size:.78rem;height:180px;overflow:auto;margin:0;color:var(--text-muted)">← Click Load Log to view recent mail activity</pre>
|
||
</div>`;
|
||
}
|
||
window.adminViewMailQueue = async () => {
|
||
const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}});
|
||
const out = r?.data?.output || 'Queue is empty';
|
||
const el = document.getElementById('ms-queue-out');
|
||
if (el) el.innerHTML = `<pre style="margin:0">${Nova.escHtml(out)}</pre>`;
|
||
else Nova.modal('Mail Queue', `<pre style="background:var(--bg);padding:1rem;font-size:.8rem;overflow:auto;max-height:400px">${Nova.escHtml(out)}</pre>`);
|
||
};
|
||
|
||
// ── FTP Server ────────────────────────────────────────────────────────────
|
||
async function ftpServer() {
|
||
const [sRes, optsRes] = await Promise.all([
|
||
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';
|
||
const ftpR = await Nova.api('ftp','list',{params:{account_id:0}});
|
||
const ftpAccts = ftpR?.data || [];
|
||
|
||
return `
|
||
<div class="page-header mb-2"><h2 class="page-title">FTP Server</h2></div>
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">${label}</span>
|
||
${Nova.badge(status, status==='active'?'green':'red')}
|
||
<div style="display:flex;gap:.35rem;margin-left:auto">
|
||
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','restart')">↺ Restart</button>
|
||
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')">⟳ Reload</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body" style="font-size:.85rem;color:var(--text-muted)">
|
||
Active server: <strong>${label}</strong> — change in <a href="#" onclick="adminPage('server-options')">Server Options</a>.
|
||
Passive FTP ports 20/21 · SFTP on port 22.
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">All FTP Accounts (${ftpAccts.length})</span></div>
|
||
${ftpAccts.length ? `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Account</th><th>Directory</th><th>Permissions</th></tr></thead><tbody>
|
||
${ftpAccts.map(f=>`<tr>
|
||
<td><strong>${Nova.escHtml(f.username)}</strong></td>
|
||
<td style="font-size:.82rem">${Nova.escHtml(f.account_domain||String(f.account_id)||'—')}</td>
|
||
<td style="font-size:.75rem;font-family:monospace">${Nova.escHtml(f.home_dir||'—')}</td>
|
||
<td>${Nova.badge(f.permissions||'rw','blue')}</td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>`
|
||
: '<div class="card-body text-muted">No FTP accounts yet — created from each account's FTP page.</div>'}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Backups — delegates to backupsFull() defined in additions ─────────────
|
||
async function backups() { return backupsFull(); }
|
||
|
||
// ── Stubs for new pages — implementations in additions block below ─────────
|
||
async function wordpress() { return wordpressPage(); }
|
||
async function cloudflare() { return cloudflarePage(); }
|
||
async function twofa() { return twofaPage(); }
|
||
async function nginxProxy() { return nginxProxyPage(); }
|
||
async function sessions() { return sessionsPage(); }
|
||
|
||
// ── Global action helpers ──────────────────────────────────────────────────
|
||
window.adminPage = (page) => Nova.loadPage(page, pages);
|
||
|
||
window.applyNovaCPXUpdate = async () => {
|
||
Nova.confirm('Apply NovaCPX update? PHP syntax is checked first, and a backup is taken automatically. The panel will self-restore if anything breaks.', async () => {
|
||
Nova.loading('Pulling NovaCPX update from GitHub…');
|
||
const res = await Nova.api('system', 'apply-update', { method: 'POST' });
|
||
Nova.loadingDone();
|
||
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.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.', 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);
|
||
});
|
||
};
|
||
|
||
// keep old alias for any lingering references
|
||
window.applyUpdate = window.applyNovaCPXUpdate;
|
||
window.adminServiceAction = async (svc, cmd) => {
|
||
const label = { start: 'Starting', stop: 'Stopping', restart: 'Restarting', reload: 'Reloading', flush: 'Flushing queue' }[cmd] || cmd;
|
||
Nova.loading(`${label} ${svc}…`);
|
||
// Optimistic immediate badge update
|
||
const optimistic = cmd === 'stop' ? 'inactive' : cmd === 'flush' ? null : 'activating';
|
||
if (optimistic) {
|
||
document.querySelectorAll(`[data-svc-status="${svc}"]`).forEach(el => {
|
||
el.innerHTML = Nova.badge(optimistic, optimistic === 'inactive' ? 'red' : 'yellow');
|
||
});
|
||
document.querySelectorAll(`[data-svc-dot="${svc}"]`).forEach(el => {
|
||
el.innerHTML = Nova.serviceDot(optimistic);
|
||
});
|
||
}
|
||
const res = await Nova.api('system', 'service', { method: 'POST', body: { service: svc, command: cmd } });
|
||
Nova.loadingDone();
|
||
if (res?.success) {
|
||
const msg = cmd === 'flush' ? `Mail queue flushed` : `${svc} ${cmd} complete`;
|
||
Nova.toast(msg, 'success');
|
||
if (cmd !== 'flush') window.refreshSvcStatus(svc);
|
||
} else {
|
||
Nova.toast(res?.message || `${svc} ${cmd} failed`, 'error');
|
||
if (cmd !== 'flush') window.refreshSvcStatus(svc, 0);
|
||
}
|
||
};
|
||
|
||
// Polls is-active and updates all [data-svc-status] / [data-svc-dot] in the DOM
|
||
window.refreshSvcStatus = async (svc, delay = 2000) => {
|
||
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||
const r = await Nova.api('system', 'svc-check', { params: { service: svc } });
|
||
const status = r?.data?.status || 'unknown';
|
||
const color = status === 'active' ? 'green' : status === 'activating' ? 'yellow' : 'red';
|
||
document.querySelectorAll(`[data-svc-status="${svc}"]`).forEach(el => {
|
||
el.innerHTML = Nova.badge(status, color);
|
||
});
|
||
document.querySelectorAll(`[data-svc-dot="${svc}"]`).forEach(el => {
|
||
el.innerHTML = Nova.serviceDot(status);
|
||
});
|
||
};
|
||
window.phpAction = async (ver, cmd) => {
|
||
const svc = `php${ver}-fpm`;
|
||
await window.adminServiceAction(svc, 'restart');
|
||
};
|
||
|
||
// ── Check for updates badge ────────────────────────────────────────────────
|
||
async function checkUpdates() {
|
||
const [ncpx, os] = await Promise.all([
|
||
Nova.api('system', 'check-novacpx-update'),
|
||
Nova.api('system', 'check-os-update'),
|
||
]);
|
||
const ncpxN = ncpx?.data?.updates_available || 0;
|
||
const osN = os?.data?.upgradable || 0;
|
||
const total = ncpxN + osN;
|
||
const badge = document.getElementById('update-badge');
|
||
if (badge && total > 0) { badge.textContent = total; badge.style.display = ''; }
|
||
}
|
||
})();
|
||
// ── ADDITIONS: appended by features #14-17 ────────────────────────────────
|
||
|
||
// ── WordPress Manager (#14) ────────────────────────────────────────────────
|
||
async function wordpressPage() {
|
||
const [acctRes, wpRes] = await Promise.all([
|
||
Nova.api('accounts','list',{params:{limit:500}}),
|
||
Nova.api('wordpress','list'),
|
||
]);
|
||
const accts = acctRes?.data || [];
|
||
const installs = wpRes?.data?.installs || [];
|
||
window._adminAcctsWP = accts;
|
||
|
||
return `
|
||
<div class="page-header mb-3">
|
||
<h2 class="page-title">WordPress Manager</h2>
|
||
<button class="btn btn-primary" onclick="wpInstallModal()">+ Install WordPress</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">WordPress Installs</span>
|
||
<span class="text-muted text-sm ml-2">${installs.length} install${installs.length!==1?'s':''}</span>
|
||
<button class="btn btn-ghost btn-sm ml-auto" onclick="adminPage('wordpress')">↻ Refresh</button>
|
||
</div>
|
||
${installs.length ? `
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Domain</th><th>Path</th><th>Account</th><th>Version</th><th>Status</th><th>Actions</th></tr></thead>
|
||
<tbody>
|
||
${installs.map(w => `<tr>
|
||
<td><strong>${Nova.escHtml(w.domain)}</strong></td>
|
||
<td><code>${Nova.escHtml(w.path||'/')}</code></td>
|
||
<td>${Nova.escHtml(w.username||'—')}</td>
|
||
<td>${w.wp_version ? `<code>${Nova.escHtml(w.wp_version)}</code>` : '—'}</td>
|
||
<td>${Nova.badge(w.status||'active', w.status==='active'?'green':w.status==='updating'?'yellow':'red')}</td>
|
||
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
||
<button class="btn btn-xs" onclick="wpInfo(${w.id},'${Nova.escHtml(w.domain)}')">Info</button>
|
||
<button class="btn btn-xs btn-primary" onclick="wpUpdate(${w.id},'core')">Update Core</button>
|
||
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'plugins')">Plugins</button>
|
||
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'themes')">Themes</button>
|
||
${!w.staging_of ? `<button class="btn btn-xs" onclick="wpCloneStaging(${w.id},'${Nova.escHtml(w.domain)}')">Clone Staging</button>` : `<span class="badge badge-yellow">staging</span>`}
|
||
<button class="btn btn-xs btn-danger" onclick="wpDelete(${w.id},'${Nova.escHtml(w.domain)}')">Delete</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : `<div class="empty" style="padding:2rem">No WordPress installs yet. Click "Install WordPress" to get started.</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
window.wpInstallModal = () => {
|
||
const accts = window._adminAcctsWP || [];
|
||
const opts = accts.map(a => `<option value="${a.id}">${a.username} — ${a.domain}</option>`).join('');
|
||
Nova.modal('Install WordPress', `
|
||
<div class="form-group"><label>Account</label><select id="wp-acct" class="form-control">${opts}</select></div>
|
||
<div class="form-group"><label>Domain</label><input id="wp-domain" class="form-control" placeholder="example.com"></div>
|
||
<div class="form-group">
|
||
<label>Install Path</label>
|
||
<div style="display:flex;gap:.5rem;align-items:center">
|
||
<select id="wp-path-preset" class="form-control" style="flex:0 0 auto;width:auto" onchange="wpPathPreset(this)">
|
||
<option value="/">/ (site root)</option>
|
||
<option value="/blog">/blog</option>
|
||
<option value="/wp">/wp</option>
|
||
<option value="/wordpress">/wordpress</option>
|
||
<option value="/cms">/cms</option>
|
||
<option value="__custom">Custom…</option>
|
||
</select>
|
||
<input id="wp-path" class="form-control" value="/" placeholder="/path" style="display:none">
|
||
</div>
|
||
<p class="text-muted text-sm mt-1">Install at site root (/) unless you want WordPress at a subdirectory.</p>
|
||
</div>
|
||
<div class="form-group"><label>Site Title</label><input id="wp-title" class="form-control" placeholder="My WordPress Site"></div>
|
||
<div class="form-group"><label>WP Admin Username</label><input id="wp-admin" class="form-control" value="admin"></div>
|
||
<div class="form-group"><label>WP Admin Password</label><input id="wp-adminpass" type="password" class="form-control"></div>
|
||
<div class="form-group"><label>WP Admin Email</label><input id="wp-email" type="email" class="form-control"></div>
|
||
<p class="text-muted text-sm">wp-cli will be downloaded automatically if not installed. Installation takes 1-2 minutes — a live terminal will show progress.</p>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" id="wp-install-btn" onclick="wpSubmitInstall()">Install</button>`);
|
||
};
|
||
|
||
window.wpPathPreset = (sel) => {
|
||
const pathInput = document.getElementById('wp-path');
|
||
if (sel.value === '__custom') {
|
||
pathInput.style.display = '';
|
||
pathInput.value = '/';
|
||
pathInput.focus();
|
||
} else {
|
||
pathInput.style.display = 'none';
|
||
pathInput.value = sel.value;
|
||
}
|
||
};
|
||
|
||
window.wpSubmitInstall = () => {
|
||
const acctId = +document.getElementById('wp-acct')?.value;
|
||
const domain = document.getElementById('wp-domain')?.value?.trim();
|
||
const preset = document.getElementById('wp-path-preset')?.value;
|
||
const path = preset === '__custom'
|
||
? (document.getElementById('wp-path')?.value?.trim() || '/')
|
||
: (preset || '/');
|
||
const title = document.getElementById('wp-title')?.value?.trim();
|
||
const admin = document.getElementById('wp-admin')?.value?.trim();
|
||
const pass = document.getElementById('wp-adminpass')?.value;
|
||
const email = document.getElementById('wp-email')?.value?.trim();
|
||
|
||
if (!domain) { Nova.toast('Domain is required','error'); return; }
|
||
if (!title) { Nova.toast('Site title is required','error'); return; }
|
||
if (!admin) { Nova.toast('Admin username is required','error'); return; }
|
||
if (!pass) { Nova.toast('Admin password is required','error'); return; }
|
||
if (!email) { Nova.toast('Admin email is required','error'); return; }
|
||
|
||
// Close form modal, open terminal modal
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
|
||
const termId = 'wp-term-' + Date.now();
|
||
Nova.modal('Installing WordPress', `
|
||
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
|
||
padding:1rem;border-radius:6px;height:280px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||
<span style="color:#7ec8e3">Connecting to server…</span>\n
|
||
</div>`,
|
||
`<button class="btn btn-ghost" id="wp-term-cancel" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
|
||
|
||
const term = document.getElementById(termId);
|
||
const append = (text) => {
|
||
term.textContent += text;
|
||
term.scrollTop = term.scrollHeight;
|
||
};
|
||
|
||
const body = JSON.stringify({ account_id: acctId, domain, path, site_title: title,
|
||
admin_user: admin, admin_pass: pass, admin_email: email });
|
||
|
||
fetch(`/api/wordpress/install-stream`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body,
|
||
credentials: 'same-origin',
|
||
}).then(resp => {
|
||
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
const read = () => reader.read().then(({ done, value }) => {
|
||
if (done) { append('\n[stream closed]'); return; }
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const obj = JSON.parse(m[1]);
|
||
if (obj.line) {
|
||
// Check for __DONE__ sentinel
|
||
if (obj.line.startsWith('__DONE__')) {
|
||
try {
|
||
const result = JSON.parse(obj.line.slice(8));
|
||
append(`\n✓ WordPress installed! Admin: ${result.admin_user} | ID #${result.id}\n`);
|
||
} catch(e) { append('\n✓ WordPress installed!\n'); }
|
||
} else {
|
||
append(obj.line);
|
||
}
|
||
} else if (obj.error) {
|
||
append(`\n✗ Error: ${obj.error}\n`);
|
||
} else if (obj.done) {
|
||
const cancelBtn = document.getElementById('wp-term-cancel');
|
||
if (cancelBtn) {
|
||
cancelBtn.textContent = 'Done';
|
||
cancelBtn.className = 'btn btn-primary';
|
||
cancelBtn.onclick = () => {
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
adminPage('wordpress');
|
||
};
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
read();
|
||
}).catch(err => append(`\n[connection error: ${err.message}]`));
|
||
read();
|
||
}).catch(err => append(`\nFetch error: ${err.message}`));
|
||
};
|
||
|
||
window.wpUpdate = async (id, type) => {
|
||
const action = type === 'core' ? 'update-core' : type === 'plugins' ? 'update-plugins' : 'update-themes';
|
||
Nova.toast(`Updating ${type}…`,'info',15000);
|
||
const r = await Nova.api('wordpress', action, {method:'POST',body:{install_id:id}});
|
||
Nova.toast(r?.message || (r?.success ? 'Updated' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('wordpress');
|
||
};
|
||
|
||
window.wpInfo = async (id, domain) => {
|
||
Nova.toast('Loading info…','info',5000);
|
||
const r = await Nova.api('wordpress','info',{params:{install_id:id}});
|
||
if (!r?.success) { Nova.toast(r?.message,'error'); return; }
|
||
const d = r.data || {};
|
||
const plugins = (d.plugins||[]).map(p => `<tr><td>${Nova.escHtml(p.name)}</td><td>${Nova.escHtml(p.version||'')}</td><td>${Nova.badge(p.status||'inactive',p.status==='active'?'green':'muted')}</td></tr>`).join('');
|
||
const themes = (d.themes||[]).map(t => `<tr><td>${Nova.escHtml(t.name)}</td><td>${Nova.escHtml(t.version||'')}</td><td>${Nova.badge(t.status||'inactive',t.status==='active'?'green':'muted')}</td></tr>`).join('');
|
||
Nova.modal(`WordPress: ${domain}`,`
|
||
<div class="grid-2 mb-2" style="gap:.75rem">
|
||
<div><p class="text-muted text-sm">Core Version</p><p class="font-bold">${Nova.escHtml(d.version||'—')}</p></div>
|
||
<div><p class="text-muted text-sm">Site URL</p><p>${Nova.escHtml(d.siteurl||'—')}</p></div>
|
||
</div>
|
||
<h4 class="mb-1">Plugins (${(d.plugins||[]).length})</h4>
|
||
${plugins ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Plugin</th><th>Version</th><th>Status</th></tr></thead><tbody>${plugins}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}
|
||
<h4 class="mb-1 mt-2">Themes (${(d.themes||[]).length})</h4>
|
||
${themes ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Theme</th><th>Version</th><th>Status</th></tr></thead><tbody>${themes}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}`);
|
||
};
|
||
|
||
window.wpCloneStaging = (id, domain) => {
|
||
Nova.confirm(`Clone ${domain} to a staging environment? This copies all files and the database.`, async () => {
|
||
Nova.toast('Cloning to staging…','info',30000);
|
||
const r = await Nova.api('wordpress','clone-staging',{method:'POST',body:{install_id:id}});
|
||
Nova.toast(r?.message || (r?.success ? 'Staging created' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('wordpress');
|
||
});
|
||
};
|
||
|
||
window.wpDelete = (id, domain) => {
|
||
Nova.confirm(`DELETE WordPress install on ${domain}? This removes all files AND drops the database. IRREVERSIBLE.`, async () => {
|
||
const r = await Nova.api('wordpress','delete',{method:'POST',body:{install_id:id}});
|
||
Nova.toast(r?.message || (r?.success ? 'Deleted' : 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) adminPage('wordpress');
|
||
}, true);
|
||
};
|
||
|
||
// ── Backup Manager — full implementation (#15) ─────────────────────────────
|
||
async function backupsFull() {
|
||
const [acctRes, bkRes] = await Promise.all([
|
||
Nova.api('accounts','list',{params:{limit:500}}),
|
||
Nova.api('backup','list'),
|
||
]);
|
||
const accts = acctRes?.data || [];
|
||
const backupList = bkRes?.data?.backups || [];
|
||
const diskUsed = bkRes?.data?.disk_used || 0;
|
||
window._adminAcctsBK = accts;
|
||
|
||
return `
|
||
<div class="page-header mb-3">
|
||
<h2 class="page-title">Backup Manager</h2>
|
||
<div style="display:flex;gap:.5rem">
|
||
<button class="btn btn-primary" onclick="bkCreateModal()">+ New Backup</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="adminPage('backups')">↻ Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-grid mb-3" style="grid-template-columns:repeat(3,1fr)">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Total Backups</div>
|
||
<div class="stat-value stat-blue">${backupList.length}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Disk Used</div>
|
||
<div class="stat-value">${Nova.bytes(diskUsed)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Accounts</div>
|
||
<div class="stat-value">${accts.length}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-3">
|
||
<div class="card-header">
|
||
<span class="card-title">Backup Schedules</span>
|
||
<button class="btn btn-sm" onclick="bkScheduleModal()">Configure Schedule</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-muted text-sm">Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.</p>
|
||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem">
|
||
${accts.slice(0,8).map(a => `<button class="btn btn-xs" onclick="bkScheduleForAccount(${a.id},'${Nova.escHtml(a.username)}')">${Nova.escHtml(a.username)}</button>`).join('')}
|
||
${accts.length>8?`<span class="text-muted text-sm">+${accts.length-8} more</span>`:''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">All Backups</span></div>
|
||
${backupList.length ? `
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Account</th><th>Type</th><th>Size</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
|
||
<tbody>
|
||
${backupList.map(b => `<tr>
|
||
<td>${Nova.escHtml(b.username||b.account_id||'—')}</td>
|
||
<td>${Nova.badge(b.type,'default')}</td>
|
||
<td>${Nova.bytes(b.size||0)}</td>
|
||
<td>${Nova.badge(b.status, b.status==='complete'?'green':b.status==='failed'?'red':'yellow')}</td>
|
||
<td>${b.remote_path ? Nova.badge('remote','blue') : Nova.badge('local','muted')}</td>
|
||
<td class="text-muted text-sm">${Nova.relTime(b.created_at)}</td>
|
||
<td style="display:flex;gap:.25rem">
|
||
${b.status==='complete'?`<a class="btn btn-xs" href="/api/backup/download?id=${b.id}" target="_blank">Download</a>`:''}
|
||
<button class="btn btn-xs btn-warning" onclick="bkRestore(${b.id})">Restore</button>
|
||
<button class="btn btn-xs btn-danger" onclick="bkDelete(${b.id})">Del</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : `<div class="empty" style="padding:2rem">No backups yet.</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
window.bkCreateModal = () => {
|
||
const accts = window._adminAcctsBK || [];
|
||
const opts = accts.map(a => `<option value="${a.id}">${a.username} — ${a.domain}</option>`).join('');
|
||
Nova.modal('Create Backup', `
|
||
<div class="form-group"><label>Account</label><select id="bk-acct" class="form-control">${opts}</select></div>
|
||
<div class="form-group"><label>Type</label>
|
||
<select id="bk-type" class="form-control">
|
||
<option value="full">Full (files + database)</option>
|
||
<option value="files">Files only</option>
|
||
<option value="database">Database only</option>
|
||
</select>
|
||
</div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="bkSubmitCreate()">Create Backup</button>`);
|
||
};
|
||
|
||
window.bkSubmitCreate = async () => {
|
||
const id = +document.getElementById('bk-acct')?.value;
|
||
const type = document.getElementById('bk-type')?.value;
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
Nova.toast('Creating backup…','info',30000);
|
||
const r = await Nova.api('backup','create',{method:'POST',body:{account_id:id,type}});
|
||
Nova.toast(r?.message||(r?.success?'Backup complete':'Failed'), r?.success?'success':'error');
|
||
if (r?.success) adminPage('backups');
|
||
};
|
||
|
||
window.bkRestore = (id) => {
|
||
Nova.confirm('Restore this backup? Current files and databases will be overwritten. IRREVERSIBLE.', async () => {
|
||
Nova.toast('Restoring…','info',30000);
|
||
const r = await Nova.api('backup','restore',{method:'POST',body:{id}});
|
||
Nova.toast(r?.message||(r?.success?'Restored':'Failed'), r?.success?'success':'error');
|
||
}, true);
|
||
};
|
||
|
||
window.bkDelete = (id) => {
|
||
Nova.confirm('Delete this backup?', async () => {
|
||
const r = await Nova.api('backup','delete',{method:'POST',body:{id}});
|
||
Nova.toast(r?.message||(r?.success?'Deleted':'Failed'), r?.success?'success':'error');
|
||
if (r?.success) adminPage('backups');
|
||
}, true);
|
||
};
|
||
|
||
window.bkScheduleModal = () => {
|
||
const accts = window._adminAcctsBK || [];
|
||
const opts = accts.map(a => `<option value="${a.id}">${a.username}</option>`).join('');
|
||
Nova.modal('Configure Backup Schedule', `
|
||
<div class="form-group"><label>Account</label><select id="bks-acct" class="form-control">${opts}</select></div>
|
||
<div class="form-group"><label>Frequency</label>
|
||
<select id="bks-freq" class="form-control">
|
||
<option value="hourly">Hourly</option>
|
||
<option value="daily" selected>Daily</option>
|
||
<option value="weekly">Weekly</option>
|
||
<option value="monthly">Monthly</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group"><label>Type</label>
|
||
<select id="bks-type" class="form-control">
|
||
<option value="full">Full</option>
|
||
<option value="files">Files only</option>
|
||
<option value="database">Database only</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="7"></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="bkSaveSchedule()">Save Schedule</button>`);
|
||
};
|
||
|
||
window.bkScheduleForAccount = async (id, user) => {
|
||
const r = await Nova.api('backup','get-schedule',{params:{account_id:id}});
|
||
const s = r?.data || {};
|
||
Nova.modal(`Schedule: ${user}`, `
|
||
<div class="form-group"><label>Frequency</label>
|
||
<select id="bks-freq" class="form-control">
|
||
${['hourly','daily','weekly','monthly'].map(f=>`<option value="${f}"${s.frequency===f?' selected':''}>${f.charAt(0).toUpperCase()+f.slice(1)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="form-group"><label>Type</label>
|
||
<select id="bks-type" class="form-control">
|
||
${['full','files','database'].map(t=>`<option value="${t}"${s.type===t?' selected':''}>${t.charAt(0).toUpperCase()+t.slice(1)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="${s.retain_count||7}"></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="bkSaveScheduleFor(${id})">Save</button>`);
|
||
};
|
||
|
||
window.bkSaveSchedule = async () => {
|
||
const id = +document.getElementById('bks-acct')?.value;
|
||
await bkSaveScheduleFor(id);
|
||
};
|
||
|
||
window.bkSaveScheduleFor = async (id) => {
|
||
const r = await Nova.api('backup','schedule',{method:'POST',body:{
|
||
account_id: id,
|
||
frequency: document.getElementById('bks-freq')?.value,
|
||
type: document.getElementById('bks-type')?.value,
|
||
retain: +document.getElementById('bks-retain')?.value,
|
||
}});
|
||
document.querySelector('.modal-overlay')?.remove();
|
||
Nova.toast(r?.message||(r?.success?'Schedule saved':'Failed'), r?.success?'success':'error');
|
||
};
|
||
|
||
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
|
||
async function cloudflarePage() {
|
||
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
|
||
const accts = acctRes?.data || [];
|
||
window._adminAcctsCF = accts;
|
||
|
||
return `
|
||
<div class="page-header mb-3">
|
||
<h2 class="page-title">Cloudflare Integration</h2>
|
||
<p class="text-muted text-sm">Manage Cloudflare API credentials and DNS sync per account.</p>
|
||
</div>
|
||
|
||
<div class="card mb-3">
|
||
<div class="card-header"><span class="card-title">Account Credentials</span></div>
|
||
<div class="card-body">
|
||
<p class="text-muted text-sm mb-2">Select an account to configure or view its Cloudflare API key.</p>
|
||
<div style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
|
||
<div class="form-group mb-0">
|
||
<label class="form-label text-sm">Account</label>
|
||
<select id="cf-acct-sel" class="form-control form-control-sm" onchange="cfLoadAccount(this.value)">
|
||
<option value="">— Select Account —</option>
|
||
${accts.map(a=>`<option value="${a.id}">${a.username} — ${a.domain}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div id="cf-acct-panel" style="margin-top:1rem"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" id="cf-zones-panel" style="display:none">
|
||
<div class="card-header">
|
||
<span class="card-title">Cloudflare Zones</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="cfRefreshZones()">↻ Refresh Zones</button>
|
||
</div>
|
||
<div id="cf-zones-body" class="card-body">
|
||
<p class="text-muted text-sm">Save credentials first, then click Refresh Zones.</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
window.cfLoadAccount = async (id) => {
|
||
if (!id) { document.getElementById('cf-acct-panel').innerHTML=''; return; }
|
||
const r = await Nova.api('cloudflare','get-credentials',{params:{account_id:id}});
|
||
const c = r?.data || {};
|
||
document.getElementById('cf-acct-panel').innerHTML = `
|
||
<div class="grid-2" style="gap:.75rem;max-width:600px">
|
||
<div class="form-group"><label class="form-label">API Email</label>
|
||
<input id="cf-email" class="form-control" type="email" value="${Nova.escHtml(c.cf_api_email||'')}" placeholder="you@example.com"></div>
|
||
<div class="form-group"><label class="form-label">Global API Key</label>
|
||
<input id="cf-apikey" class="form-control" type="text" value="${Nova.escHtml(c.cf_api_key||'')}" placeholder="API key from Cloudflare dashboard"></div>
|
||
</div>
|
||
<div style="display:flex;gap:.5rem;margin-top:.5rem">
|
||
<button class="btn btn-sm btn-primary" onclick="cfSaveCredentials(${id})">Save Credentials</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="cfTestKey(${id})">Test API Key</button>
|
||
</div>
|
||
${c.cf_api_key ? `<p class="text-muted text-sm mt-1">Key on file: <code>${Nova.escHtml(c.cf_api_key)}</code></p>` : ''}`;
|
||
document.getElementById('cf-zones-panel').style.display = '';
|
||
window._cfCurrentAcct = id;
|
||
};
|
||
|
||
window.cfSaveCredentials = async (id) => {
|
||
const r = await Nova.api('cloudflare','save-credentials',{method:'POST',body:{
|
||
account_id: id,
|
||
api_key: document.getElementById('cf-apikey')?.value,
|
||
email: document.getElementById('cf-email')?.value,
|
||
}});
|
||
Nova.toast(r?.message||(r?.success?'Saved':'Failed'), r?.success?'success':'error');
|
||
};
|
||
|
||
window.cfTestKey = async (id) => {
|
||
const r = await Nova.api('cloudflare','test-key',{method:'POST',body:{
|
||
account_id: id,
|
||
api_key: document.getElementById('cf-apikey')?.value,
|
||
email: document.getElementById('cf-email')?.value,
|
||
}});
|
||
Nova.toast(r?.message||(r?.data?.valid?'API key is valid':'Invalid key'), r?.data?.valid?'success':'error');
|
||
};
|
||
|
||
window.cfRefreshZones = async () => {
|
||
const id = window._cfCurrentAcct;
|
||
if (!id) { Nova.toast('Select an account first','error'); return; }
|
||
const r = await Nova.api('cloudflare','list-zones',{params:{account_id:id}});
|
||
const zones = r?.data?.zones || r?.data || [];
|
||
const body = document.getElementById('cf-zones-body');
|
||
if (!body) return;
|
||
if (!r?.success) { body.innerHTML=`<p class="text-muted">${Nova.escHtml(r?.message||'Failed to load zones')}</p>`; return; }
|
||
if (!zones.length) { body.innerHTML='<p class="text-muted text-sm">No zones found for these credentials.</p>'; return; }
|
||
body.innerHTML = `
|
||
<table class="table">
|
||
<thead><tr><th>Zone</th><th>Status</th><th>Plan</th><th>Actions</th></tr></thead>
|
||
<tbody>
|
||
${zones.map(z=>`<tr>
|
||
<td><strong>${Nova.escHtml(z.name)}</strong><br><code style="font-size:.75rem">${Nova.escHtml(z.id)}</code></td>
|
||
<td>${Nova.badge(z.status,z.status==='active'?'green':'yellow')}</td>
|
||
<td class="text-muted text-sm">${Nova.escHtml(z.plan?.name||'—')}</td>
|
||
<td style="display:flex;gap:.25rem">
|
||
<button class="btn btn-xs" onclick="cfViewRecords('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}',${id})">DNS Records</button>
|
||
<button class="btn btn-xs btn-primary" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','to',${id})">Push to CF</button>
|
||
<button class="btn btn-xs" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','from',${id})">Pull from CF</button>
|
||
<button class="btn btn-xs btn-warning" onclick="cfPurge('${Nova.escHtml(z.id)}',${id})">Purge Cache</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`;
|
||
};
|
||
|
||
window.cfViewRecords = async (zoneId, domain, acctId) => {
|
||
const r = await Nova.api('cloudflare','list-records',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
|
||
const records = r?.data?.records || r?.data || [];
|
||
Nova.modal(`CF DNS: ${domain}`, !records.length ? '<p class="text-muted">No records.</p>' : `
|
||
<table class="table" style="font-size:.82rem">
|
||
<thead><tr><th>Name</th><th>Type</th><th>Value</th><th>Proxy</th></tr></thead>
|
||
<tbody>
|
||
${records.map(rec=>`<tr>
|
||
<td>${Nova.escHtml(rec.name)}</td>
|
||
<td>${Nova.badge(rec.type,'default')}</td>
|
||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><code>${Nova.escHtml(rec.content)}</code></td>
|
||
<td>
|
||
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer">
|
||
<input type="checkbox" ${rec.proxiable&&rec.proxied?'checked':''} ${!rec.proxiable?'disabled':''}
|
||
onchange="cfToggleProxy('${zoneId}','${rec.id}',this.checked,${acctId})">
|
||
${rec.proxied?Nova.badge('proxied','orange'):Nova.badge('DNS only','muted')}
|
||
</label>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`);
|
||
};
|
||
|
||
window.cfToggleProxy = async (zoneId, recordId, proxied, acctId) => {
|
||
const r = await Nova.api('cloudflare','toggle-proxy',{method:'POST',body:{zone_id:zoneId,record_id:recordId,proxied,account_id:acctId}});
|
||
Nova.toast(r?.message||(r?.success?'Updated':'Failed'), r?.success?'success':'error');
|
||
};
|
||
|
||
window.cfSync = async (zoneId, domain, dir, acctId) => {
|
||
const action = dir==='to' ? 'sync-to-cf' : 'sync-from-cf';
|
||
const label = dir==='to' ? 'Pushing to Cloudflare' : 'Pulling from Cloudflare';
|
||
Nova.toast(`${label}…`,'info',10000);
|
||
const r = await Nova.api('cloudflare',action,{method:'POST',body:{zone_id:zoneId,domain,account_id:acctId}});
|
||
Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error');
|
||
};
|
||
|
||
window.cfPurge = async (zoneId, acctId) => {
|
||
Nova.confirm('Purge all Cloudflare cache for this zone?', async () => {
|
||
const r = await Nova.api('cloudflare','purge-cache',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
|
||
Nova.toast(r?.message||(r?.success?'Cache purged':'Failed'), r?.success?'success':'error');
|
||
});
|
||
};
|
||
|
||
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
|
||
async function twofaPage() {
|
||
const res = await Nova.api('accounts','list',{params:{limit:500}});
|
||
const users = res?.data || [];
|
||
return `
|
||
<div class="page-header mb-3">
|
||
<h2 class="page-title">Two-Factor Authentication</h2>
|
||
<p class="text-muted text-sm">View 2FA status for all users. Force-disable for account recovery.</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">User 2FA Status</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="adminPage('twofa')">↻ Refresh</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>2FA Status</th><th>Actions</th></tr></thead>
|
||
<tbody id="totp-user-rows">
|
||
${users.map(u=>`<tr>
|
||
<td><strong>${Nova.escHtml(u.username)}</strong></td>
|
||
<td class="text-muted text-sm">${Nova.escHtml(u.email||'—')}</td>
|
||
<td>${Nova.badge(u.role||'user','default')}</td>
|
||
<td id="totp-status-${u.id}">
|
||
<span class="text-muted text-sm">—</span>
|
||
</td>
|
||
<td>
|
||
<button class="btn btn-xs btn-ghost" onclick="totpCheckStatus(${u.id})">Check</button>
|
||
<button class="btn btn-xs btn-warning" onclick="totpAdminDisable(${u.id},'${Nova.escHtml(u.username)}')">Force Disable</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
window.totpCheckStatus = async (userId) => {
|
||
const r = await Nova.api('totp','admin-status',{method:'POST',body:{user_id:userId}});
|
||
const el = document.getElementById(`totp-status-${userId}`);
|
||
if (!el) return;
|
||
const enabled = r?.data?.totp_enabled;
|
||
el.innerHTML = enabled
|
||
? Nova.badge('Enabled','green')
|
||
: Nova.badge('Disabled','muted');
|
||
};
|
||
|
||
window.totpAdminDisable = (userId, username) => {
|
||
Nova.confirm(`Force-disable 2FA for ${username}? Use only for account recovery when user cannot log in.`, async () => {
|
||
const r = await Nova.api('totp','admin-disable',{method:'POST',body:{user_id:userId}});
|
||
Nova.toast(r?.message||(r?.success?'2FA disabled':'Failed'), r?.success?'success':'error');
|
||
if (r?.success) {
|
||
const el = document.getElementById(`totp-status-${userId}`);
|
||
if (el) el.innerHTML = Nova.badge('Disabled','muted');
|
||
}
|
||
}, true);
|
||
};
|
||
|
||
// ── Nginx Proxy Manager ───────────────────────────────────────────────────────
|
||
async function nginxProxyPage() {
|
||
const [statusR, hostsR, settingsR] = await Promise.all([
|
||
Nova.api('proxy', 'status'),
|
||
Nova.api('proxy', 'hosts'),
|
||
Nova.api('proxy', 'settings'),
|
||
]);
|
||
const s = statusR?.data || {};
|
||
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
|
||
const cfg = settingsR?.data || {};
|
||
const run = s.running;
|
||
const inst = s.installed;
|
||
const isRemote = cfg.mode === 'remote';
|
||
const modeLabel = cfg.mode === 'remote' ? `Remote (${cfg.remote_host || 'unconfigured'})` : (cfg.mode === 'local' ? 'Local' : 'Disabled');
|
||
|
||
return `
|
||
<div class="page-header">
|
||
<h1 class="page-title">Nginx Proxy Manager</h1>
|
||
<div class="page-actions">
|
||
<button class="btn btn-ghost btn-sm" onclick="proxySettings()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||
Settings
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="proxySetupInstructions()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
|
||
Setup Guide
|
||
</button>
|
||
${isRemote && cfg.remote_host ? `
|
||
<button class="btn btn-ghost btn-sm" onclick="proxyRunSetup()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||
${inst ? 'Re-run Setup' : 'Run Setup on Remote VM'}
|
||
</button>
|
||
` : ''}
|
||
${inst ? `
|
||
<button class="btn btn-sm btn-secondary" onclick="proxySync()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
Sync Accounts
|
||
</button>
|
||
<button class="btn btn-sm btn-danger btn-ghost" onclick="proxyUninstall()" style="margin-left:0.25rem">Uninstall</button>
|
||
<button class="btn btn-sm btn-primary" onclick="proxyAddHost()">+ Add Host</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Nginx Status</div>
|
||
<div class="stat-value ${run ? 'stat-green' : 'stat-red'}">${cfg.mode === 'disabled' ? 'Disabled' : (run ? 'Running' : 'Stopped')}</div>
|
||
<div class="stat-sub">${s.version || (inst ? 'nginx' : 'configure in Settings')}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Mode</div>
|
||
<div class="stat-value" style="font-size:1rem">${modeLabel}</div>
|
||
<div class="stat-sub">${isRemote ? 'configs pushed via SSH' : (cfg.mode === 'local' ? 'nginx on this VM' : 'click Settings to enable')}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Proxy Hosts</div>
|
||
<div class="stat-value">${hosts.length}</div>
|
||
<div class="stat-sub">${hosts.filter(h => h.enabled).length} active</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">SSL Enabled</div>
|
||
<div class="stat-value">${hosts.filter(h => h.ssl_enabled).length}</div>
|
||
<div class="stat-sub">of ${hosts.length} hosts</div>
|
||
</div>
|
||
</div>
|
||
|
||
${(!inst || cfg.mode === 'disabled') ? `
|
||
<div class="panel" style="padding:2rem">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;max-width:680px;margin:0 auto">
|
||
<div style="border:1px solid var(--border);border-radius:8px;padding:1.5rem;text-align:center">
|
||
<div style="font-size:2rem;margin-bottom:0.5rem">🖥</div>
|
||
<h4 style="margin-bottom:0.5rem">Local Mode</h4>
|
||
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:1rem">nginx on <em>this server</em>. Apache moves to an internal port. All websites keep working — nginx proxies everything through. One-click setup.</p>
|
||
<button class="btn btn-primary btn-sm" onclick="proxySwitchLocal()">Enable Local Mode</button>
|
||
</div>
|
||
<div style="border:1px solid var(--border);border-radius:8px;padding:1.5rem;text-align:center">
|
||
<div style="font-size:2rem;margin-bottom:0.5rem">🌐</div>
|
||
<h4 style="margin-bottom:0.5rem">Remote Proxy VM</h4>
|
||
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:1rem">Dedicated LXC or VM runs nginx. Panel pushes configs via SSH. Best for production — keeps proxy and hosting isolated.</p>
|
||
<button class="btn btn-secondary btn-sm" onclick="proxySettings()">Configure Remote VM</button>
|
||
</div>
|
||
</div>
|
||
<div style="text-align:center;margin-top:1.25rem">
|
||
<button class="btn btn-ghost btn-sm" onclick="proxySetupInstructions()">Setup Guide & Requirements</button>
|
||
</div>
|
||
</div>
|
||
` : `
|
||
<div class="panel" style="margin-bottom:1.5rem">
|
||
<div class="panel-header">
|
||
<h3 class="panel-title">Service Controls</h3>
|
||
<div style="display:flex;gap:0.5rem">
|
||
<button class="btn btn-sm btn-success" onclick="proxyControl('start')">Start</button>
|
||
<button class="btn btn-sm btn-warning" onclick="proxyControl('restart')">Restart</button>
|
||
<button class="btn btn-sm btn-danger" onclick="proxyControl('stop')">Stop</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="proxyControl('reload')">Reload Config</button>
|
||
${cfg.mode === 'local' ? `<button class="btn btn-sm btn-ghost" style="margin-left:0.5rem" onclick="proxyDisableLocal()">Disable Local Mode</button>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-header">
|
||
<h3 class="panel-title">Proxy Hosts</h3>
|
||
<span class="badge badge-blue">${hosts.length} total</span>
|
||
</div>
|
||
${hosts.length === 0 ? `
|
||
<div style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||
No proxy hosts yet. Click <strong>Sync Accounts</strong> to auto-add all hosted domains, or <strong>+ Add Host</strong> to add manually.
|
||
</div>
|
||
` : `
|
||
<div style="overflow-x:auto">
|
||
<table class="table">
|
||
<thead><tr>
|
||
<th>Domain</th>
|
||
<th>Upstream</th>
|
||
<th>SSL</th>
|
||
<th>Status</th>
|
||
<th>Actions</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${hosts.map(h => `
|
||
<tr id="proxy-row-${h.id}">
|
||
<td><strong>${Nova.escHtml(h.domain)}</strong></td>
|
||
<td style="font-family:monospace;font-size:0.8rem">${Nova.escHtml(h.upstream)}</td>
|
||
<td>${h.ssl_enabled ? Nova.badge('SSL','green') : Nova.badge('HTTP','muted')}</td>
|
||
<td>${h.enabled ? Nova.badge('Active','green') : Nova.badge('Disabled','red')}</td>
|
||
<td>
|
||
<button class="btn btn-xs btn-ghost" onclick="proxyEditHost(${h.id})">Edit</button>
|
||
<button class="btn btn-xs ${h.enabled ? 'btn-warning' : 'btn-success'}" onclick="proxyToggle(${h.id},${h.enabled ? 0 : 1})">${h.enabled ? 'Disable' : 'Enable'}</button>
|
||
<button class="btn btn-xs btn-danger" onclick="proxyDeleteHost(${h.id},'${Nova.escHtml(h.domain)}')">Delete</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`}`;
|
||
}
|
||
|
||
window.proxyInstall = async () => {
|
||
if (!confirm('Install Nginx on this VM? This will run apt-get install nginx.')) return;
|
||
Nova.toast('Installing nginx...', 'info');
|
||
const r = await Nova.api('proxy', 'install', { method: 'POST' });
|
||
Nova.toast(r?.data?.result || r?.message || 'Done', r?.data?.result === 'installed' ? 'success' : 'info');
|
||
Nova.loadPage('nginx-proxy', window._novaPages);
|
||
};
|
||
|
||
window.proxyControl = async (action) => {
|
||
Nova.loading(action.charAt(0).toUpperCase() + action.slice(1) + 'ing nginx…');
|
||
const r = await Nova.api('proxy', 'control', { method: 'POST', body: { action } });
|
||
Nova.loadingDone();
|
||
const ok = r?.success;
|
||
const msg = r?.data?.result || r?.message || (ok ? action + ' done' : action + ' failed');
|
||
Nova.toast(msg, ok ? 'success' : 'error');
|
||
Nova.loadPage('nginx-proxy', window._novaPages);
|
||
};
|
||
|
||
window.proxySync = async () => {
|
||
const r = await Nova.api('proxy', 'sync', { method: 'POST' });
|
||
Nova.toast(`Synced: ${r?.data?.added ?? 0} new hosts added`, 'success');
|
||
Nova.loadPage('nginx-proxy', window._novaPages);
|
||
};
|
||
|
||
window.proxyAddHost = () => {
|
||
const ov = Nova.modal('Add Proxy Host', `
|
||
<div class="form-group"><label>Domain</label>
|
||
<input id="ph-domain" type="text" placeholder="example.com" class="form-control"></div>
|
||
<div class="form-group"><label>Upstream URL</label>
|
||
<input id="ph-upstream" type="text" value="http://127.0.0.1:80" class="form-control">
|
||
<small class="text-muted">e.g. http://127.0.0.1:80 or http://10.0.0.2:8080</small></div>
|
||
<div class="form-group">
|
||
<label><input type="checkbox" id="ph-ssl"> Enable SSL</label></div>
|
||
<div class="form-group"><label>Notes (optional)</label>
|
||
<input id="ph-notes" type="text" class="form-control"></div>
|
||
`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" id="ph-save-btn">Add Host</button>`
|
||
);
|
||
ov.querySelector('#ph-save-btn').addEventListener('click', async () => {
|
||
const domain = document.getElementById('ph-domain')?.value?.trim();
|
||
const upstream = document.getElementById('ph-upstream')?.value?.trim();
|
||
if (!domain || !upstream) { Nova.toast('Domain and upstream required', 'error'); return; }
|
||
const btn = ov.querySelector('#ph-save-btn');
|
||
btn.disabled = true; btn.textContent = 'Adding…';
|
||
const r = await Nova.api('proxy', 'hosts', {
|
||
method: 'POST',
|
||
body: { domain, upstream, ssl_enabled: document.getElementById('ph-ssl')?.checked ? 1 : 0 }
|
||
});
|
||
Nova.toast(r?.success ? 'Host added' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); }
|
||
else { btn.disabled = false; btn.textContent = 'Add Host'; }
|
||
});
|
||
};
|
||
|
||
window.proxyEditHost = async (id) => {
|
||
const hostsR = await Nova.api('proxy', 'hosts');
|
||
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
|
||
const h = hosts.find(x => x.id == id);
|
||
if (!h) return;
|
||
const ov = Nova.modal('Edit Proxy Host', `
|
||
<div class="form-group"><label>Domain</label>
|
||
<input id="phe-domain" type="text" value="${Nova.escHtml(h.domain)}" class="form-control"></div>
|
||
<div class="form-group"><label>Upstream URL</label>
|
||
<input id="phe-upstream" type="text" value="${Nova.escHtml(h.upstream)}" class="form-control"></div>
|
||
<div class="form-group">
|
||
<label><input type="checkbox" id="phe-ssl" ${h.ssl_enabled ? 'checked' : ''}> Enable SSL</label></div>
|
||
<div class="form-group"><label>Custom Nginx Config (overrides auto-generated)</label>
|
||
<textarea id="phe-custom" rows="6" class="form-control" style="font-family:monospace;font-size:0.78rem">${Nova.escHtml(h.custom_config || '')}</textarea>
|
||
<small class="text-muted">Leave blank to use auto-generated config</small></div>
|
||
`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" id="phe-save-btn">Save Changes</button>`
|
||
);
|
||
ov.querySelector('#phe-save-btn').addEventListener('click', async () => {
|
||
const btn = ov.querySelector('#phe-save-btn');
|
||
btn.disabled = true; btn.textContent = 'Saving…';
|
||
const r = await Nova.api('proxy', 'host', {
|
||
method: 'PUT',
|
||
body: { id,
|
||
domain: document.getElementById('phe-domain')?.value?.trim(),
|
||
upstream: document.getElementById('phe-upstream')?.value?.trim(),
|
||
ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0,
|
||
custom_config: document.getElementById('phe-custom')?.value?.trim() || null,
|
||
}
|
||
});
|
||
Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); }
|
||
else { btn.disabled = false; btn.textContent = 'Save Changes'; }
|
||
});
|
||
};
|
||
|
||
window.proxyToggle = async (id, enable) => {
|
||
const r = await Nova.api('proxy', 'toggle', { method: 'POST', body: { id, enabled: enable } });
|
||
Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error');
|
||
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
|
||
};
|
||
|
||
window.proxyDeleteHost = (id, domain) => {
|
||
Nova.confirm(`Delete proxy host for ${domain}?`, async () => {
|
||
const r = await Nova.api('proxy', 'host', { method: 'DELETE', body: { id } });
|
||
Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error');
|
||
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
|
||
}, true);
|
||
};
|
||
|
||
window.proxySetupInstructions = async () => {
|
||
Nova.modal('Nginx Proxy Setup Guide', `
|
||
<div style="max-height:65vh;overflow-y:auto;line-height:1.7">
|
||
|
||
<div style="background:var(--bg-secondary);border-left:3px solid var(--color-primary);padding:0.75rem 1rem;border-radius:0 6px 6px 0;margin-bottom:1.5rem">
|
||
<strong>Designed for Proxmox (or any Linux hypervisor)</strong><br>
|
||
<span style="color:var(--text-muted);font-size:0.88rem">
|
||
Run NovaCPX on one VM and a lightweight Debian LXC as the nginx proxy.
|
||
The panel pushes configs and controls nginx via SSH.
|
||
Works equally well on VMware, AWS, DigitalOcean, bare-metal — see Option C below.
|
||
</span>
|
||
</div>
|
||
|
||
<h4 style="margin-bottom:0.5rem">Option A — Proxmox LXC (Recommended)</h4>
|
||
<p style="color:var(--text-muted);margin-bottom:0.75rem">Create a 512MB Debian 12 LXC on the same Proxmox node. Costs almost no resources.</p>
|
||
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem">
|
||
<li>In Proxmox: Create CT → Debian 12 → 512MB RAM, 8GB disk, same bridge as NovaCPX VM</li>
|
||
<li>Boot the LXC, set root password</li>
|
||
<li>Go to <strong>Settings</strong> → set Mode=Remote, enter the LXC IP, root password, and this VM's IP as Backend IP</li>
|
||
<li>Click <strong>Run Setup on Remote VM</strong> — watch live progress</li>
|
||
<li>Point your router/firewall port 80/443 to the LXC IP</li>
|
||
<li>Click <strong>Sync Accounts</strong> to auto-populate proxy hosts</li>
|
||
</ol>
|
||
|
||
<h4 style="margin-bottom:0.5rem">Option B — Other hypervisors (VMware, Hyper-V, KVM)</h4>
|
||
<p style="color:var(--text-muted);margin-bottom:0.75rem">Same flow — any Debian/Ubuntu VM reachable by SSH works.</p>
|
||
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem">
|
||
<li>Create a Debian/Ubuntu VM (1 vCPU, 512MB RAM)</li>
|
||
<li>Enable SSH root login: <code>PermitRootLogin yes</code> in <code>/etc/ssh/sshd_config</code></li>
|
||
<li>Install <code>sshpass</code> on the NovaCPX server: <code>apt-get install -y sshpass</code></li>
|
||
<li>Follow steps 3–6 from Option A above</li>
|
||
</ol>
|
||
|
||
<h4 style="margin-bottom:0.5rem">Option C — Cloud / Remote Server (AWS, DigitalOcean, etc.)</h4>
|
||
<p style="color:var(--text-muted);margin-bottom:0.75rem">NovaCPX pushes configs via public SSH. The proxy VM's public IP handles port 80/443; it forwards to NovaCPX over a private network or VPN.</p>
|
||
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem">
|
||
<li>Provision a small Debian droplet/instance in the same region or with low latency to NovaCPX</li>
|
||
<li>Open port 22 (SSH) from NovaCPX's IP only; open 80/443 from anywhere</li>
|
||
<li>Set Backend IP to NovaCPX's IP reachable from the cloud proxy (use VPN/private IP if possible)</li>
|
||
<li>In Settings: set Remote Host to the cloud server's public IP or hostname</li>
|
||
<li>Click Run Setup, then Sync Accounts</li>
|
||
</ol>
|
||
|
||
<h4 style="margin-bottom:0.5rem">Option D — Local nginx on this VM</h4>
|
||
<p style="color:var(--text-muted);margin-bottom:0.75rem">Not recommended — requires moving Apache off port 80/443 first.</p>
|
||
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem">
|
||
<li>Edit <code>/etc/apache2/ports.conf</code> → change <code>Listen 80</code> to <code>Listen 8090</code>, restart Apache</li>
|
||
<li>Set Settings → Mode = Local, Backend IP = 127.0.0.1</li>
|
||
<li>Click <strong>Install Nginx Locally</strong></li>
|
||
<li>Set upstream <code>http://127.0.0.1:8090</code> on all proxy hosts</li>
|
||
<li>Click Sync Accounts</li>
|
||
</ol>
|
||
|
||
<h4 style="margin-bottom:0.5rem">Settings Reference (Admin → Nginx Proxy → Settings)</h4>
|
||
<table style="width:100%;font-size:0.83rem;border-collapse:collapse;color:var(--text-muted)">
|
||
<tr style="border-bottom:1px solid var(--border)"><th style="text-align:left;padding:0.3rem 0.5rem">Field</th><th style="text-align:left;padding:0.3rem 0.5rem">Description</th></tr>
|
||
<tr><td style="padding:0.3rem 0.5rem"><code>Mode</code></td><td style="padding:0.3rem 0.5rem">disabled / remote / local</td></tr>
|
||
<tr><td style="padding:0.3rem 0.5rem"><code>Remote Host</code></td><td style="padding:0.3rem 0.5rem">IP or hostname of nginx proxy VM (SSH target)</td></tr>
|
||
<tr><td style="padding:0.3rem 0.5rem"><code>Remote User</code></td><td style="padding:0.3rem 0.5rem">SSH user on proxy VM (default: root)</td></tr>
|
||
<tr><td style="padding:0.3rem 0.5rem"><code>Remote Password</code></td><td style="padding:0.3rem 0.5rem">SSH password (stored encrypted in DB)</td></tr>
|
||
<tr><td style="padding:0.3rem 0.5rem"><code>Backend IP</code></td><td style="padding:0.3rem 0.5rem">IP of this NovaCPX Apache — used in auto-generated proxy upstream URLs</td></tr>
|
||
</table>
|
||
|
||
<h4 style="margin-bottom:0.5rem;margin-top:1.25rem">How it works</h4>
|
||
<ul style="color:var(--text-muted);padding-left:1.2rem;margin-bottom:0">
|
||
<li>Each domain gets an nginx vhost config on the proxy VM, proxying to Apache on the backend IP</li>
|
||
<li>Configs are pushed automatically when accounts are created/terminated or manually via Sync Accounts</li>
|
||
<li>The panel starts/stops/reloads nginx on the proxy VM over SSH</li>
|
||
<li>Every 5 minutes the health check verifies nginx is running and restarts it if not</li>
|
||
<li>Use <strong>Uninstall</strong> to remove proxy configs or wipe nginx from the remote VM entirely</li>
|
||
</ul>
|
||
</div>
|
||
`, null, { cancelLabel: 'Close', showConfirm: false });
|
||
};
|
||
|
||
window.proxySwitchLocal = () => {
|
||
const slOv = Nova.modal('Enable Local Nginx Proxy', `
|
||
<p style="margin-bottom:1rem">Nginx will be installed on <em>this server</em> and take over ports 80/443. Apache moves to an internal port and keeps serving all PHP sites — end users see no change.</p>
|
||
<div style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-size:0.85rem;margin-bottom:1rem">
|
||
<strong>What will happen:</strong><br>
|
||
<span style="color:var(--text-muted)">
|
||
1. nginx installed (if not present)<br>
|
||
2. Apache moved from port 80 → <strong id="sl-port-preview">8090</strong><br>
|
||
3. All existing vhosts updated<br>
|
||
4. nginx starts on port 80/443 and proxies to Apache<br>
|
||
5. Proxy hosts auto-synced from your accounts
|
||
</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Apache backend port <small class="text-muted">(any unused port; 8090 is the default)</small></label>
|
||
<input id="sl-port" type="number" class="form-control" value="8090" min="1024" max="65535"
|
||
oninput="document.getElementById('sl-port-preview').textContent=this.value">
|
||
</div>
|
||
`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" id="sl-switch-btn">Switch Now</button>`
|
||
);
|
||
slOv.querySelector('#sl-switch-btn').addEventListener('click', () => {
|
||
slOv.remove();
|
||
const port = parseInt(document.getElementById('sl-port')?.value) || 8090;
|
||
const ov = Nova.modal('Switching to Local Proxy Mode', `
|
||
<p style="color:var(--text-muted);margin-bottom:0.75rem">Moving Apache to port ${port} and starting nginx on 80/443…</p>
|
||
<pre id="proxy-local-log" style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-size:0.78rem;max-height:320px;overflow-y:auto;white-space:pre-wrap;font-family:monospace">Starting…\n</pre>
|
||
`, null, { cancelLabel: 'Close', showConfirm: false });
|
||
|
||
const log = document.getElementById('proxy-local-log');
|
||
let done = false;
|
||
|
||
fetch('/api/proxy/switch-local', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ apache_port: port }),
|
||
}).then(async res => {
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
while (true) {
|
||
const { value, done: d } = await reader.read();
|
||
if (d) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const evt = JSON.parse(m[1]);
|
||
if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; }
|
||
if (evt.done) { done = true; log.textContent += '\n— Done.\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1500); }
|
||
} catch {}
|
||
}
|
||
}
|
||
}).catch(e => { log.textContent += '\n— Connection error: ' + e.message + '\n'; });
|
||
|
||
ov.querySelector('.modal-close')?.addEventListener('click', () => { done = true; });
|
||
});
|
||
};
|
||
|
||
window.proxyDisableLocal = () => {
|
||
Nova.confirm('Revert to direct Apache mode? nginx will be stopped and Apache will move back to port 80.', () => {
|
||
const ov = Nova.modal('Disabling Local Proxy Mode', `
|
||
<pre id="proxy-disable-log" style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-size:0.78rem;max-height:240px;overflow-y:auto;white-space:pre-wrap;font-family:monospace">Starting…\n</pre>
|
||
`, null, { cancelLabel: 'Close', showConfirm: false });
|
||
const log = document.getElementById('proxy-disable-log');
|
||
fetch('/api/proxy/disable-local', { method: 'POST', credentials: 'include' }).then(async res => {
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (m) try {
|
||
const evt = JSON.parse(m[1]);
|
||
if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; }
|
||
if (evt.done) setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1000);
|
||
} catch {}
|
||
}
|
||
}
|
||
});
|
||
}, true);
|
||
};
|
||
|
||
window.proxyRunSetup = () => {
|
||
const ov = Nova.modal('Setting Up Remote Nginx Proxy', `
|
||
<p style="color:var(--text-muted);margin-bottom:0.75rem">Running setup on the remote proxy VM — this takes about 30 seconds.</p>
|
||
<pre id="proxy-setup-log" style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-size:0.78rem;max-height:320px;overflow-y:auto;white-space:pre-wrap;font-family:monospace">Connecting…\n</pre>
|
||
`);
|
||
|
||
const log = document.getElementById('proxy-setup-log');
|
||
let done = false;
|
||
|
||
fetch('/api/proxy/setup-remote', { method: 'POST', credentials: 'include' })
|
||
.then(async res => {
|
||
if (!res.ok) { log.textContent += '\n— Server error (' + res.status + '). Check remote host settings.\n'; return; }
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
while (true) {
|
||
const { value, done: d } = await reader.read();
|
||
if (d) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const evt = JSON.parse(m[1]);
|
||
if (evt.line) { log.textContent += evt.line; log.scrollTop = log.scrollHeight; }
|
||
if (evt.done) { done = true; log.textContent += '\n— Done. Refreshing status…\n'; setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 1200); }
|
||
} catch {}
|
||
}
|
||
}
|
||
})
|
||
.catch(e => { log.textContent += '\n— Connection error: ' + e.message + '\n'; });
|
||
|
||
ov.querySelector('.modal-close')?.addEventListener('click', () => { done = true; });
|
||
};
|
||
|
||
window.proxyUninstall = () => {
|
||
const ov = Nova.modal('Uninstall Nginx Proxy', `
|
||
<p>Choose what to remove from the <strong>remote proxy VM</strong>:</p>
|
||
<div class="form-group" style="margin-top:1rem">
|
||
<label><input type="radio" name="uninst" value="configs" checked> Remove proxy host configs only <small class="text-muted">(keep nginx running)</small></label><br>
|
||
<label style="margin-top:0.5rem"><input type="radio" name="uninst" value="full"> Remove everything <small class="text-muted">(uninstall nginx, delete all configs, disable proxy mode)</small></label>
|
||
</div>
|
||
`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-danger" id="uninst-btn">Uninstall</button>`
|
||
);
|
||
ov.querySelector('#uninst-btn').addEventListener('click', async () => {
|
||
const btn = ov.querySelector('#uninst-btn');
|
||
btn.disabled = true; btn.textContent = 'Removing…';
|
||
const full = ov.querySelector('input[name="uninst"]:checked')?.value === 'full';
|
||
const r = await Nova.api('proxy', 'uninstall', { method: 'DELETE', body: { remove_nginx: full } });
|
||
Nova.toast(r?.data?.result || r?.message || 'Done', r?.success ? 'success' : 'error');
|
||
if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); }
|
||
else { btn.disabled = false; btn.textContent = 'Uninstall'; }
|
||
});
|
||
};
|
||
|
||
window.proxySettings = async () => {
|
||
const r = await Nova.api('proxy', 'settings');
|
||
const cfg = r?.data || {};
|
||
const ov = Nova.modal('Nginx Proxy Settings', `
|
||
<div class="form-group">
|
||
<label>Proxy Mode</label>
|
||
<select id="ps-mode" class="form-control" onchange="document.getElementById('ps-remote-fields').style.display=this.value==='remote'?'':'none'">
|
||
<option value="disabled" ${cfg.mode==='disabled'?'selected':''}>Disabled</option>
|
||
<option value="remote" ${cfg.mode==='remote' ?'selected':''}>Remote VM (SSH)</option>
|
||
<option value="local" ${cfg.mode==='local' ?'selected':''}>Local (nginx on this VM)</option>
|
||
</select>
|
||
</div>
|
||
<div id="ps-remote-fields" style="display:${cfg.mode==='remote'?'':'none'}">
|
||
<div class="form-group">
|
||
<label>Remote Host <small class="text-muted">(IP of your nginx proxy VM)</small></label>
|
||
<input id="ps-host" type="text" class="form-control" placeholder="10.48.200.112" value="${Nova.escHtml(cfg.remote_host||'')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>SSH User</label>
|
||
<input id="ps-user" type="text" class="form-control" value="${Nova.escHtml(cfg.remote_user||'root')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>SSH Password</label>
|
||
<input id="ps-pass" type="password" class="form-control" placeholder="${cfg.remote_pass?'(saved — leave blank to keep)':'Enter password'}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Backend IP <small class="text-muted">(NovaCPX Apache IP — used in proxy host upstreams)</small></label>
|
||
<input id="ps-backend" type="text" class="form-control" placeholder="10.48.200.110" value="${Nova.escHtml(cfg.backend_ip||'')}">
|
||
</div>
|
||
<div style="margin-bottom:1rem">
|
||
<button class="btn btn-sm btn-ghost" onclick="proxyTestRemote()">Test Connection</button>
|
||
<span id="ps-test-result" style="margin-left:0.75rem;font-size:0.85rem"></span>
|
||
</div>
|
||
</div>
|
||
`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" id="ps-save-btn">Save Settings</button>`
|
||
);
|
||
ov.querySelector('#ps-save-btn').addEventListener('click', async () => {
|
||
const btn = ov.querySelector('#ps-save-btn');
|
||
btn.disabled = true; btn.textContent = 'Saving…';
|
||
const mode = document.getElementById('ps-mode')?.value;
|
||
const pass = document.getElementById('ps-pass')?.value;
|
||
const body = {
|
||
mode,
|
||
remote_host: document.getElementById('ps-host')?.value?.trim() || '',
|
||
remote_user: document.getElementById('ps-user')?.value?.trim() || 'root',
|
||
remote_pass: pass || '••••••••',
|
||
backend_ip: document.getElementById('ps-backend')?.value?.trim() || '',
|
||
};
|
||
const r = await Nova.api('proxy', 'settings', { method: 'POST', body });
|
||
Nova.toast(r?.success ? 'Settings saved' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) { ov.remove(); Nova.loadPage('nginx-proxy', window._novaPages); }
|
||
else { btn.disabled = false; btn.textContent = 'Save Settings'; }
|
||
});
|
||
};
|
||
|
||
window.proxyTestRemote = async () => {
|
||
const host = document.getElementById('ps-host')?.value?.trim();
|
||
const user = document.getElementById('ps-user')?.value?.trim() || 'root';
|
||
const pass = document.getElementById('ps-pass')?.value;
|
||
const el = document.getElementById('ps-test-result');
|
||
if (!host) { if (el) el.textContent = 'Enter a host first'; return; }
|
||
if (el) el.textContent = 'Testing…';
|
||
// Save current fields temporarily so the test can use them
|
||
await Nova.api('proxy', 'settings', { method: 'POST', body: {
|
||
remote_host: host, remote_user: user,
|
||
remote_pass: pass || '••••••••',
|
||
}});
|
||
const r = await Nova.api('proxy', 'test-remote', { method: 'POST' });
|
||
const d = r?.data || {};
|
||
if (el) {
|
||
el.style.color = d.ok ? 'var(--color-success)' : 'var(--color-error)';
|
||
el.textContent = d.message || (d.ok ? 'Connected' : 'Failed');
|
||
}
|
||
};
|
||
|
||
// ── #29 Session Manager ───────────────────────────────────────────────────────
|
||
async function sessionsPage() {
|
||
const r = await Nova.api('sessions', 'list');
|
||
const rows = r?.data || [];
|
||
const fmt = d => new Date(d.replace(' ','T')+'Z').toLocaleString();
|
||
const ua = s => {
|
||
if (!s) return '—';
|
||
const m = s.match(/\(([^)]+)\)/);
|
||
return m ? m[1].split(';')[0].slice(0,50) : s.slice(0,50);
|
||
};
|
||
return `
|
||
<div class="page-header">
|
||
<h1 class="page-title">Session Manager</h1>
|
||
<div class="page-actions">
|
||
<button class="btn btn-sm btn-danger" onclick="sessionsRevokeAll()">Revoke All Sessions</button>
|
||
</div>
|
||
</div>
|
||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||
<div class="stat-card"><div class="stat-label">Active Sessions</div><div class="stat-value">${rows.length}</div></div>
|
||
<div class="stat-card"><div class="stat-label">Unique Users</div><div class="stat-value">${new Set(rows.map(r=>r.user_id)).size}</div></div>
|
||
<div class="stat-card"><div class="stat-label">Unique IPs</div><div class="stat-value">${new Set(rows.map(r=>r.ip_address)).size}</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<div class="panel-header"><h3 class="panel-title">Active Sessions</h3><span class="badge badge-blue">${rows.length} total</span></div>
|
||
${rows.length === 0
|
||
? '<div style="padding:2rem;text-align:center;color:var(--text-muted)">No active sessions</div>'
|
||
: `<div style="overflow-x:auto"><table class="table"><thead><tr>
|
||
<th>User</th><th>Role</th><th>IP</th><th>Browser</th><th>Created</th><th>Expires</th><th>Actions</th>
|
||
</tr></thead><tbody>
|
||
${rows.map(s=>`<tr>
|
||
<td><strong>${Nova.escHtml(s.username)}</strong><br><small class="text-muted">${Nova.escHtml(s.email)}</small></td>
|
||
<td>${Nova.badge(s.role, s.role==='admin'?'red':s.role==='reseller'?'yellow':'blue')}</td>
|
||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(s.ip_address)}</td>
|
||
<td style="font-size:.8rem;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${Nova.escHtml(s.user_agent||'')}">${Nova.escHtml(ua(s.user_agent||''))}</td>
|
||
<td style="font-size:.82rem">${fmt(s.created_at)}</td>
|
||
<td style="font-size:.82rem">${fmt(s.expires_at)}</td>
|
||
<td>
|
||
<button class="btn btn-xs btn-danger" onclick="sessionsRevoke('${s.id}')">Revoke</button>
|
||
<button class="btn btn-xs btn-warning" onclick="sessionsRevokeUser(${s.user_id},'${Nova.escHtml(s.username)}')">All for User</button>
|
||
</td></tr>`).join('')}
|
||
</tbody></table></div>`}
|
||
</div>`;
|
||
}
|
||
|
||
window.sessionsRevoke = async (id) => {
|
||
const r = await Nova.api('sessions','revoke',{method:'DELETE',body:{session_id:id}});
|
||
Nova.toast(r?.success?'Session revoked':'Failed',r?.success?'success':'error');
|
||
if (r?.success) Nova.loadPage('sessions',window._novaPages);
|
||
};
|
||
|
||
window.sessionsRevokeUser = (uid,name) => {
|
||
Nova.confirm(`Revoke all sessions for ${name}? They will be logged out everywhere.`,async()=>{
|
||
const r=await Nova.api('sessions','revoke-user',{method:'DELETE',body:{user_id:uid}});
|
||
Nova.toast(r?.success?`${r.data?.revoked??'?'} sessions revoked`:'Failed',r?.success?'success':'error');
|
||
if(r?.success) Nova.loadPage('sessions',window._novaPages);
|
||
},true);
|
||
};
|
||
|
||
window.sessionsRevokeAll = () => {
|
||
Nova.confirm('Revoke ALL sessions? Everyone including you will be logged out.',async()=>{
|
||
const r=await Nova.api('sessions','revoke-all',{method:'DELETE',body:{}});
|
||
Nova.toast(r?.success?'All sessions revoked — logging out...':'Failed',r?.success?'success':'error');
|
||
if(r?.success) setTimeout(()=>location.reload(),1500);
|
||
},true);
|
||
};
|
||
|
||
// ── #31-35 Docker Management ───────────────────────────────────────────────
|
||
async function docker() {
|
||
const st = await Nova.api('docker', 'status');
|
||
const status = st?.data || {};
|
||
|
||
window.dockerInstall = async (btn) => {
|
||
btn.disabled = true;
|
||
Nova.loading('Installing Docker CE… (this may take 2–3 minutes)');
|
||
const r = await Nova.api('docker', 'install', { method: 'POST', body: {} });
|
||
Nova.loadingDone();
|
||
Nova.toast(r?.message || (r?.success ? 'Docker installed' : 'Install failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) Nova.loadPage('docker', window._novaPages);
|
||
else btn.disabled = false;
|
||
};
|
||
|
||
if (!status.installed) {
|
||
return `
|
||
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
||
<div class="card"><div class="card-body" style="text-align:center;padding:3rem">
|
||
<div style="font-size:3rem;margin-bottom:1rem">🐳</div>
|
||
<h3>Docker is not installed</h3>
|
||
<p class="text-muted" style="margin:.5rem 0 1.5rem">Install Docker CE + Compose on this server to enable container management.</p>
|
||
<button class="btn btn-primary" onclick="dockerInstall(this)">Install Docker CE</button>
|
||
</div></div>`;
|
||
}
|
||
|
||
window._dockerTab = window._dockerTab || 'containers';
|
||
const tab = (id, label) => `<button class="btn btn-sm ${id===window._dockerTab?'btn-primary':'btn-ghost'}" onclick="dockerTab('${id}')">${label}</button>`;
|
||
|
||
window.dockerTab = async (id) => {
|
||
window._dockerTab = id;
|
||
document.querySelectorAll('[onclick^="dockerTab"]').forEach(b => {
|
||
b.className = 'btn btn-sm ' + (b.getAttribute('onclick').includes(`'${id}'`) ? 'btn-primary' : 'btn-ghost');
|
||
});
|
||
await dockerLoadTab(id);
|
||
};
|
||
window.dockerPrune = () => Nova.confirm('Remove all stopped containers, unused images, and build cache?', async () => {
|
||
const r = await Nova.api('docker', 'prune', { method: 'POST', body: { volumes: false } });
|
||
Nova.toast(r?.success ? 'Pruned' : 'Failed', r?.success ? 'success' : 'error');
|
||
if (r?.success) dockerLoadTab(window._dockerTab);
|
||
}, true);
|
||
|
||
setTimeout(() => dockerLoadTab(window._dockerTab), 100);
|
||
|
||
return `
|
||
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||
<div class="stat-card"><div class="stat-label">Engine</div><div class="stat-value stat-green">${Nova.escHtml(status.version || '—')}</div><div class="stat-sub">${status.running ? 'Running' : 'Stopped'}</div></div>
|
||
${(status.disk||[]).map(d=>`<div class="stat-card"><div class="stat-label">${Nova.escHtml(d.Type||d.type||'?')}</div><div class="stat-value" style="font-size:1rem">${Nova.escHtml(d.TotalCount||d.Size||'—')}</div><div class="stat-sub">${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}</div></div>`).join('')}
|
||
</div>
|
||
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
||
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')}
|
||
<button class="btn btn-sm btn-warning" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||
</div>
|
||
<div id="docker-tab-content"><div class="loading">Loading…</div></div>`;
|
||
}
|
||
|
||
// Refresh without clearing the list first (keeps current content visible while loading)
|
||
async function dockerLoadTabKeep(tab) {
|
||
await dockerLoadTab(tab, true);
|
||
}
|
||
|
||
async function dockerLoadTab(tab, keepContent = false) {
|
||
const tc = document.getElementById('docker-tab-content');
|
||
if (!tc) return;
|
||
if (!keepContent) tc.innerHTML = '<div class="loading">Loading…</div>';
|
||
|
||
if (tab === 'containers') {
|
||
const r = await Nova.api('docker', 'containers');
|
||
const rows = r?.data?.containers || [];
|
||
tc.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||
<strong>${rows.length} containers</strong>
|
||
<button class="btn btn-sm btn-primary" onclick="dockerRunModal()">+ Run Container</button>
|
||
</div>
|
||
${rows.length === 0 ? '<div class="card"><div class="card-body text-muted" style="text-align:center;padding:2rem">No containers</div></div>' : `
|
||
<div style="overflow-x:auto"><table class="table"><thead><tr>
|
||
<th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Created</th><th>Actions</th>
|
||
</tr></thead><tbody>
|
||
${rows.map(c => `<tr data-cid="${Nova.escHtml(c.container_id||'')}">
|
||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
|
||
<td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td>
|
||
<td>${c.account_id || '—'}</td>
|
||
<td style="font-size:.8rem">${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
|
||
<td style="white-space:nowrap">
|
||
${c.status==='running'
|
||
? `<button class="btn btn-xs btn-warning" onclick="dockerContainerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>
|
||
<button class="btn btn-xs btn-ghost" onclick="dockerContainerAct('${Nova.escHtml(c.container_id||'')}','restart')">Restart</button>`
|
||
: `<button class="btn btn-xs btn-success" onclick="dockerContainerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
||
<button class="btn btn-xs btn-ghost" onclick="dockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
||
<button class="btn btn-xs btn-danger" onclick="dockerRemove('${Nova.escHtml(c.container_id||'')}')">Remove</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>`}`;
|
||
|
||
} else if (tab === 'images') {
|
||
const r = await Nova.api('docker', 'images');
|
||
const imgs = r?.data?.images || [];
|
||
tc.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||
<strong>${imgs.length} images</strong>
|
||
<button class="btn btn-sm btn-primary" onclick="dockerPullModal()">Pull Image</button>
|
||
</div>
|
||
${imgs.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No images</div>' : `
|
||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Repository</th><th>Tag</th><th>ID</th><th>Size</th><th>Actions</th></tr></thead><tbody>
|
||
${imgs.map(i => `<tr>
|
||
<td>${Nova.escHtml(i.Repository||i.repository||'—')}</td>
|
||
<td><code>${Nova.escHtml(i.Tag||i.tag||'latest')}</code></td>
|
||
<td style="font-family:monospace;font-size:.78rem">${Nova.escHtml((i.ID||i.id||'').substring(7,19))}</td>
|
||
<td>${Nova.escHtml(i.Size||i.size||'—')}</td>
|
||
<td><button class="btn btn-xs btn-danger" onclick="dockerImgRemove('${Nova.escHtml(i.ID||i.id||'')}')">Remove</button></td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>`}`;
|
||
|
||
} else if (tab === 'volumes') {
|
||
const r = await Nova.api('docker', 'volumes');
|
||
const vols = r?.data?.volumes || [];
|
||
tc.innerHTML = `<strong>${vols.length} volumes</strong>
|
||
${vols.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No volumes</div>' : `
|
||
<div style="overflow-x:auto;margin-top:1rem"><table class="table"><thead><tr><th>Name</th><th>Driver</th><th>Scope</th></tr></thead><tbody>
|
||
${vols.map(v=>`<tr><td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(v.Name||v.name||'')}</td><td>${Nova.escHtml(v.Driver||v.driver||'')}</td><td>${Nova.escHtml(v.Scope||v.scope||'')}</td></tr>`).join('')}
|
||
</tbody></table></div>`}`;
|
||
|
||
} else if (tab === 'networks') {
|
||
const r = await Nova.api('docker', 'networks');
|
||
const nets = r?.data?.networks || [];
|
||
tc.innerHTML = `<strong>${nets.length} networks</strong>
|
||
${nets.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No networks</div>' : `
|
||
<div style="overflow-x:auto;margin-top:1rem"><table class="table"><thead><tr><th>Name</th><th>Driver</th><th>Scope</th><th>ID</th></tr></thead><tbody>
|
||
${nets.map(n=>`<tr><td>${Nova.escHtml(n.Name||n.name||'')}</td><td>${Nova.escHtml(n.Driver||n.driver||'')}</td><td>${Nova.escHtml(n.Scope||n.scope||'')}</td><td style="font-family:monospace;font-size:.78rem">${Nova.escHtml((n.ID||n.id||'').substring(0,12))}</td></tr>`).join('')}
|
||
</tbody></table></div>`}`;
|
||
|
||
} else if (tab === 'stacks') {
|
||
const r = await Nova.api('docker', 'stacks');
|
||
const stacks = r?.data?.stacks || [];
|
||
tc.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||
<strong>${stacks.length} stacks</strong>
|
||
<button class="btn btn-sm btn-primary" onclick="dockerStackCreateModal()">+ Create Stack</button>
|
||
</div>
|
||
${stacks.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No compose stacks</div>' : `
|
||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Status</th><th>Account</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||
${stacks.map(s=>`<tr>
|
||
<td>${Nova.escHtml(s.name)}</td>
|
||
<td>${Nova.badge(s.status, s.status==='running'?'green':s.status==='stopped'?'red':'yellow')}</td>
|
||
<td>${s.account_id||'admin'}</td>
|
||
<td style="font-size:.8rem">${new Date(s.created_at).toLocaleDateString()}</td>
|
||
<td style="white-space:nowrap">
|
||
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
|
||
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
|
||
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
|
||
<button class="btn btn-xs btn-secondary" onclick="dockerStackReinstall(${s.id})">Reinstall</button>
|
||
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>`}`;
|
||
|
||
} else if (tab === 'catalog') {
|
||
const r = await Nova.api('docker', 'catalog');
|
||
const catalog = r?.data?.catalog || {};
|
||
tc.innerHTML = `
|
||
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.</p>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem">
|
||
${Object.entries(catalog).map(([key,app])=>`
|
||
<div class="card" style="transition:var(--transition)" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor=''">
|
||
<div class="card-body" style="text-align:center;padding:1.5rem">
|
||
<div style="font-size:1.8rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
||
<div style="font-weight:600;margin-bottom:.25rem">${Nova.escHtml(app.name)}</div>
|
||
<div style="font-size:.78rem;color:var(--text-muted);margin-bottom:.75rem">${Nova.escHtml(app.description)}</div>
|
||
<button class="btn btn-sm btn-primary" onclick="dockerAdminLaunchApp('${key}')">Launch</button>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
|
||
} else if (tab === 'quotas') {
|
||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||
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>
|
||
${users.map(u=>`<tr id="docker-quota-row-${u.user_id}">
|
||
<td>${Nova.escHtml(u.username)}</td>
|
||
<td id="dq-cnt-${u.user_id}">2</td>
|
||
<td id="dq-mem-${u.user_id}">512 MB</td>
|
||
<td id="dq-cpu-${u.user_id}">1.0</td>
|
||
<td><button class="btn btn-xs btn-primary" onclick="dockerQuotaModal(${u.user_id},'${Nova.escHtml(u.username)}')">Edit</button></td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>`;
|
||
}
|
||
}
|
||
|
||
window.dockerAdminLaunchApp = async (preselect) => {
|
||
const catRes = await Nova.api('docker', 'catalog');
|
||
const catalog = catRes?.data?.catalog || {};
|
||
const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||
const accounts = acctRes?.data || [];
|
||
const appOpts = Object.entries(catalog).map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
|
||
const acctOpts = accounts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)} (${Nova.escHtml(a.domain)})</option>`).join('');
|
||
|
||
window.dockerAdminUpdateParams = (key) => {
|
||
const app = catalog[key]; if (!app) return;
|
||
const tc = document.getElementById('dal-params'); if (!tc) return;
|
||
tc.innerHTML = (app.params||[]).map(p=>`
|
||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||
<input id="dal-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||
};
|
||
|
||
const ov = Nova.modal('Launch App (Admin)',
|
||
`<div class="form-group"><label>Account</label><select id="dal-account" class="form-control">${acctOpts}</select></div>
|
||
<div class="form-group"><label>Application</label><select id="dal-app" class="form-control" onchange="dockerAdminUpdateParams(this.value)">${appOpts}</select></div>
|
||
<div id="dal-params"></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="dockerAdminLaunchSubmit()">Launch</button>`
|
||
);
|
||
dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || '');
|
||
|
||
window.dockerAdminLaunchSubmit = async () => {
|
||
const appKey = document.getElementById('dal-app').value;
|
||
const accountId = parseInt(document.getElementById('dal-account').value);
|
||
const app = catalog[appKey]; if (!app) return;
|
||
const params = {};
|
||
(app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); });
|
||
const missing = (app.params||[]).filter(p => p.required && !params[p.key]);
|
||
if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
|
||
ov.remove();
|
||
Nova.toast(`Launching ${app.name}…`, 'info', 15000);
|
||
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } });
|
||
Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) dockerLoadTab('stacks');
|
||
};
|
||
};
|
||
|
||
window.dockerContainerAct = async (cid, action) => {
|
||
// Optimistic UI — update the row immediately so user sees feedback
|
||
const row = document.querySelector(`tr[data-cid="${cid}"]`);
|
||
if (row) {
|
||
const badge = row.querySelector('.badge');
|
||
if (badge) { badge.textContent = action === 'stop' ? 'stopping…' : action === 'start' ? 'starting…' : 'restarting…'; badge.className = 'badge badge-yellow'; }
|
||
row.querySelectorAll('button').forEach(b => b.disabled = true);
|
||
}
|
||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||
// Reload tab to show real status (don't clear first — keep current list visible)
|
||
if (r?.success || r !== null) dockerLoadTabKeep('containers');
|
||
};
|
||
|
||
window.dockerRemove = (cid) => Nova.confirm('Force remove this container?', async () => {
|
||
const row = document.querySelector(`tr[data-cid="${cid}"]`);
|
||
if (row) row.style.opacity = '0.4';
|
||
const r = await Nova.api('docker', 'container-remove', { method: 'POST', body: { container_id: cid, force: true } });
|
||
Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success || r !== null) dockerLoadTabKeep('containers');
|
||
}, true);
|
||
|
||
window.dockerLogs = async (cid, name) => {
|
||
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 200 } });
|
||
const logs = r?.data?.logs || r?.message || 'No logs';
|
||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(logs)}</pre>`);
|
||
};
|
||
|
||
window.dockerImgRemove = (id) => Nova.confirm('Remove this image? Stop any containers using it first.', async () => {
|
||
const r = await Nova.api('docker', 'image-remove', { method: 'POST', body: { image_id: id } });
|
||
Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||
dockerLoadTabKeep('images');
|
||
}, true);
|
||
|
||
window.dockerPullModal = () => {
|
||
const ov = Nova.modal('Pull Image',
|
||
`<div class="form-group"><label>Image Name</label><input id="di-image" class="form-control" placeholder="nginx:latest" autofocus></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="dockerPullSubmit()">Pull</button>`
|
||
);
|
||
window.dockerPullSubmit = async () => {
|
||
const image = document.getElementById('di-image').value.trim();
|
||
if (!image) return;
|
||
ov.remove();
|
||
Nova.toast('Pulling image…', 'info', 10000);
|
||
const r = await Nova.api('docker', 'image-pull', { method: 'POST', body: { image } });
|
||
Nova.toast(r?.success ? 'Image pulled' : (r?.message || 'Pull failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) dockerLoadTab('images');
|
||
};
|
||
};
|
||
|
||
window.dockerRunModal = () => {
|
||
const ov = Nova.modal('Run Container',
|
||
`<div class="form-group"><label>Image</label><input id="dr-image" class="form-control" placeholder="nginx:latest"></div>
|
||
<div class="form-group"><label>Name</label><input id="dr-name" class="form-control" placeholder="my-app"></div>
|
||
<div class="form-group"><label>Account ID</label><input id="dr-acct" type="number" class="form-control" placeholder="1"></div>
|
||
<div class="form-group"><label>Ports (host:container, one per line)</label><textarea id="dr-ports" class="form-control" rows="2" placeholder="8080:80"></textarea></div>
|
||
<div class="form-group"><label>Memory (MB)</label><input id="dr-mem" type="number" class="form-control" value="256"></div>
|
||
<div class="form-group"><label>CPUs</label><input id="dr-cpus" type="number" step="0.1" class="form-control" value="0.5"></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="dockerRunSubmit()">Run</button>`
|
||
);
|
||
window.dockerRunSubmit = async () => {
|
||
const image = document.getElementById('dr-image').value.trim();
|
||
const name = document.getElementById('dr-name').value.trim();
|
||
const acct = parseInt(document.getElementById('dr-acct').value) || 0;
|
||
const ports = document.getElementById('dr-ports').value.trim().split('\n').map(p=>p.trim()).filter(Boolean);
|
||
const mem = parseInt(document.getElementById('dr-mem').value) || 256;
|
||
const cpus = parseFloat(document.getElementById('dr-cpus').value) || 0.5;
|
||
if (!image || !name || !acct) { Nova.toast('Image, name and account required','error'); return; }
|
||
ov.remove();
|
||
const r = await Nova.api('docker', 'container-run', { method: 'POST', body: { image, name, account_id: acct, ports, memory_mb: mem, cpus } });
|
||
Nova.toast(r?.success ? 'Container started' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||
if (r?.success) dockerLoadTab('containers');
|
||
};
|
||
};
|
||
|
||
window.dockerStackAct = async (id, action) => {
|
||
Nova.toast(`Running docker compose ${action}…`, 'info', 5000);
|
||
const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: id, action } });
|
||
if (action === 'logs') {
|
||
Nova.modal('Stack Logs', `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'')}</pre>`);
|
||
} else {
|
||
Nova.toast(r?.success ? `Stack ${action} complete` : (r?.message||'Failed'), r?.success?'success':'error');
|
||
if (r?.success) dockerLoadTab('stacks');
|
||
}
|
||
};
|
||
|
||
window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => {
|
||
Nova.toast('Reinstalling stack…', 'info', 15000);
|
||
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } });
|
||
Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error');
|
||
if (r?.success) dockerLoadTab('stacks');
|
||
}, true);
|
||
|
||
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
|
||
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
|
||
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||
if (r?.success) dockerLoadTab('stacks');
|
||
}, true);
|
||
|
||
window.dockerStackCreateModal = () => {
|
||
const ov = Nova.modal('Create Compose Stack',
|
||
`<div class="form-group"><label>Stack Name</label><input id="dsc-name" class="form-control" placeholder="my-stack"></div>
|
||
<div class="form-group"><label>Account ID (leave blank for admin)</label><input id="dsc-acct" type="number" class="form-control"></div>
|
||
<div class="form-group"><label>docker-compose.yml content</label><textarea id="dsc-yaml" class="form-control" rows="12" style="font-family:monospace;font-size:.8rem" placeholder="version: '3.8'\nservices:\n app:\n image: nginx\n"></textarea></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="dockerStackCreateSubmit()">Create</button>`
|
||
);
|
||
window.dockerStackCreateSubmit = async () => {
|
||
const name = document.getElementById('dsc-name').value.trim();
|
||
const acct = document.getElementById('dsc-acct').value.trim();
|
||
const yaml = document.getElementById('dsc-yaml').value;
|
||
if (!name || !yaml) { Nova.toast('Name and YAML required','error'); return; }
|
||
ov.remove();
|
||
const r = await Nova.api('docker', 'stack-create', { method: 'POST', body: { name, account_id: acct||null, compose_yaml: yaml } });
|
||
Nova.toast(r?.success ? 'Stack created' : (r?.message||'Failed'), r?.success?'success':'error');
|
||
if (r?.success) dockerLoadTab('stacks');
|
||
};
|
||
};
|
||
|
||
window.dockerQuotaModal = (userId, username) => {
|
||
const ov = Nova.modal(`Docker Quota: ${username}`,
|
||
`<div class="form-group"><label>Max Containers</label><input id="dq-cnt" type="number" class="form-control" value="2" min="0"></div>
|
||
<div class="form-group"><label>Max Memory (MB)</label><input id="dq-mem" type="number" class="form-control" value="512" min="64"></div>
|
||
<div class="form-group"><label>Max CPUs</label><input id="dq-cpus" type="number" step="0.5" class="form-control" value="1.0" min="0.1"></div>`,
|
||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="dockerQuotaSubmit(${userId})">Save</button>`
|
||
);
|
||
window.dockerQuotaSubmit = async (uid) => {
|
||
const cnt = parseInt(document.getElementById('dq-cnt').value) || 2;
|
||
const mem = parseInt(document.getElementById('dq-mem').value) || 512;
|
||
const cpus = parseFloat(document.getElementById('dq-cpus').value) || 1.0;
|
||
ov.remove();
|
||
const r = await Nova.api('docker', 'quota-set', { method: 'POST', body: { user_id: uid, max_containers: cnt, max_memory_mb: mem, max_cpus: cpus } });
|
||
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 `
|
||
<div class="page-header"><h2 class="page-title">Server Options</h2></div>
|
||
|
||
<div class="grid-2 gap-2" style="margin-bottom:1.5rem">
|
||
|
||
<!-- Web Server (#22d) -->
|
||
<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:.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">
|
||
<label>Customer Hosting Web Server</label>
|
||
<select id="so-web" class="form-control">
|
||
${['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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mail Server (#22c) -->
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">Mail Server</span>${Nova.badge(opts.mail_server||'postfix-dovecot','green')}</div>
|
||
<div class="card-body">
|
||
<p class="text-muted" style="font-size:.85rem;margin-bottom:1rem">Mail stack for all hosted domains.</p>
|
||
<div class="form-group">
|
||
<label>Mail Stack</label>
|
||
<select id="so-mail" class="form-control">
|
||
<option value="postfix-dovecot" ${opts.mail_server==='postfix-dovecot'?'selected':''}>Postfix + Dovecot</option>
|
||
<option value="postfix-dovecot-rspamd" ${opts.mail_server==='postfix-dovecot-rspamd'?'selected':''}>Postfix + Dovecot + Rspamd (spam filter)</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="soSave('mail_server','so-mail','Mail server')">Save & Switch</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- FTP Server (#22a) -->
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">FTP Server</span>${Nova.badge(opts.ftp_server||'proftpd','green')}</div>
|
||
<div class="card-body">
|
||
<p class="text-muted" style="font-size:.85rem;margin-bottom:1rem">FTP server for hosting account file transfers.</p>
|
||
<div class="form-group">
|
||
<label>FTP Server</label>
|
||
<select id="so-ftp" class="form-control">
|
||
<option value="proftpd" ${opts.ftp_server==='proftpd'?'selected':''}>ProFTPD (default)</option>
|
||
<option value="vsftpd" ${opts.ftp_server==='vsftpd'?'selected':''}>vsftpd</option>
|
||
<option value="pureftpd" ${opts.ftp_server==='pureftpd'?'selected':''}>Pure-FTPd</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="soSave('ftp_server','so-ftp','FTP server')">Save & Switch</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DNS Server (#22e) -->
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">DNS Server</span>${Nova.badge(opts.dns_server||'bind9','green')}</div>
|
||
<div class="card-body">
|
||
<p class="text-muted" style="font-size:.85rem;margin-bottom:1rem">DNS server for authoritative name service.</p>
|
||
<div class="form-group">
|
||
<label>DNS Server</label>
|
||
<select id="so-dns" class="form-control">
|
||
<option value="bind9" ${opts.dns_server==='bind9'?'selected':''}>BIND9 (default)</option>
|
||
<option value="powerdns" ${opts.dns_server==='powerdns'?'selected':''}>PowerDNS</option>
|
||
<option value="nsd" ${opts.dns_server==='nsd'?'selected':''}>NSD</option>
|
||
<option value="none" ${opts.dns_server==='none'?'selected':''}>No local DNS (external API)</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="soSave('dns_server','so-dns','DNS server')">Save & Switch</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- WHMCS Bridge (#22b) -->
|
||
<div class="card" style="margin-bottom:1.5rem">
|
||
<div class="card-header">
|
||
<span class="card-title">WHMCS Billing Bridge</span>
|
||
${opts.whmcs_enabled==='1' ? Nova.badge('Enabled','green') : Nova.badge('Disabled','red')}
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-muted" style="font-size:.85rem;margin-bottom:1rem">
|
||
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.
|
||
</p>
|
||
<div class="grid-2">
|
||
<div class="form-group">
|
||
<label>WHMCS API Key</label>
|
||
<div style="display:flex;gap:.5rem">
|
||
<input id="so-whmcs-key" type="text" class="form-control" value="${Nova.escHtml(opts.whmcs_api_key||'')}" placeholder="Generate a random key">
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('so-whmcs-key').value=Array.from(crypto.getRandomValues(new Uint8Array(24)),b=>b.toString(16).padStart(2,'0')).join('')">Generate</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>WHMCS Enabled</label>
|
||
<select id="so-whmcs-enabled" class="form-control">
|
||
<option value="0" ${opts.whmcs_enabled!=='1'?'selected':''}>Disabled</option>
|
||
<option value="1" ${opts.whmcs_enabled==='1'?'selected':''}>Enabled</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Provisioning API URL (set this in WHMCS server module)</label>
|
||
<input type="text" class="form-control" readonly value="${location.protocol}//${location.hostname}:${location.port}/api/whmcs/create" onclick="this.select()">
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="soSaveWhmcs()">Save WHMCS Settings</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NS Health Checker (#22e) -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Nameserver Health</span>
|
||
<button class="btn btn-sm btn-ghost" onclick="soCheckNS()">Check All</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="grid-2" style="margin-bottom:1rem">
|
||
<div class="form-group">
|
||
<label>NS1 Hostname</label>
|
||
<input id="so-ns1" class="form-control" value="${Nova.escHtml(opts.ns1_hostname||'')}" placeholder="ns1.yourdomain.com">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>NS2 Hostname</label>
|
||
<input id="so-ns2" class="form-control" value="${Nova.escHtml(opts.ns2_hostname||'')}" placeholder="ns2.yourdomain.com">
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="soSaveNS()">Save Nameservers</button>
|
||
<div id="so-ns-results" style="margin-top:1rem"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Default Index Page Template (#39) -->
|
||
<div class="card" style="grid-column:1/-1">
|
||
<div class="card-header"><span class="card-title">Default Index Page Template</span></div>
|
||
<div class="card-body">
|
||
<p class="text-muted" style="font-size:.85rem;margin-bottom:.75rem">
|
||
HTML shown when a new hosting account is created. Use <code>{domain}</code> and <code>{username}</code> as placeholders. Leave blank to use the built-in styled template.
|
||
</p>
|
||
<textarea id="so-index-tpl" class="form-control" rows="6" style="font-family:monospace;font-size:.82rem;margin-bottom:.75rem"
|
||
placeholder="Leave blank for built-in template...">${Nova.escHtml(opts.default_index_template||'')}</textarea>
|
||
<button class="btn btn-primary btn-sm" onclick="soSaveIndexTemplate()">Save Template</button>
|
||
<button class="btn btn-ghost btn-sm" style="margin-left:.5rem" onclick="document.getElementById('so-index-tpl').value=''">Reset to Default</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
window.soSave = (key, inputId, label) => {
|
||
const val = document.getElementById(inputId)?.value;
|
||
if (!val) return;
|
||
Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, () => {
|
||
const termId = 'so-term-' + Date.now();
|
||
Nova.modal(`Switching ${label} to ${val}`, `
|
||
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
|
||
padding:1rem;border-radius:6px;height:260px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||
<span style="color:#7ec8e3">Starting…</span>\n
|
||
</div>`,
|
||
`<button class="btn btn-ghost" id="so-term-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
|
||
const term = document.getElementById(termId);
|
||
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
|
||
fetch('/api/system/service-switch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ key, value: val }),
|
||
credentials: 'same-origin',
|
||
}).then(resp => {
|
||
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
const read = () => reader.read().then(({ done, value }) => {
|
||
if (done) { append('\n[stream closed]'); return; }
|
||
buf += dec.decode(value, { stream: true });
|
||
const parts = buf.split('\n\n');
|
||
buf = parts.pop();
|
||
for (const part of parts) {
|
||
const m = part.match(/^data: (.+)$/m);
|
||
if (!m) continue;
|
||
try {
|
||
const obj = JSON.parse(m[1]);
|
||
if (obj.line) { append(obj.line); }
|
||
else if (obj.error) { append(`\n✗ ${obj.error}\n`); }
|
||
else if (obj.done) {
|
||
const btn = document.getElementById('so-term-close');
|
||
if (btn) {
|
||
btn.textContent = 'Done';
|
||
btn.className = 'btn btn-primary';
|
||
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('server-options'); };
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
read();
|
||
}).catch(err => append(`\n[error: ${err.message}]`));
|
||
read();
|
||
}).catch(err => append(`\nFetch error: ${err.message}`));
|
||
}, 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.soSaveIndexTemplate = async () => {
|
||
const tpl = document.getElementById('so-index-tpl')?.value || '';
|
||
const res = await Nova.api('system','save-option',{method:'POST',body:{key:'default_index_template',value:tpl}});
|
||
Nova.toast(res?.success ? 'Default template saved' : 'Save failed', res?.success?'success':'error');
|
||
};
|
||
|
||
window.soCheckNS = async () => {
|
||
const tc = document.getElementById('so-ns-results');
|
||
if (!tc) return;
|
||
tc.innerHTML = '<div class="loading">Checking NS records…</div>';
|
||
const r = await Nova.api('dns', 'ns-health');
|
||
const results = r?.data?.results || [];
|
||
if (!results.length) { tc.innerHTML = '<p class="text-muted">No zones to check, or DNS manager not configured.</p>'; return; }
|
||
tc.innerHTML = `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Domain</th><th>NS1</th><th>NS2</th><th>Status</th></tr></thead><tbody>
|
||
${results.map(z=>`<tr>
|
||
<td>${Nova.escHtml(z.domain)}</td>
|
||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(z.ns1||'—')}</td>
|
||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(z.ns2||'—')}</td>
|
||
<td>${z.ok ? Nova.badge('OK','green') : Nova.badge('Mismatch','red')}</td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>`;
|
||
};
|
||
|
||
// ══ ADMIN SUBDOMAINS PAGE ═════════════════════════════════════════════════
|
||
window.adminSubdomains = async function() {
|
||
document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active'));
|
||
document.querySelector('[data-page="subdomains"]')?.classList.add('active');
|
||
document.getElementById('page-title').textContent = 'All Subdomains';
|
||
document.getElementById('page-content').innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||
<p class="text-muted" style="margin:0">All subdomains across all hosting accounts.</p>
|
||
</div>
|
||
<div class="card"><div id="admin-sub-list"><div class="loading">Loading…</div></div></div>`;
|
||
const el = document.getElementById('admin-sub-list');
|
||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||
let rows = [];
|
||
for (const acct of res.data) {
|
||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||
if (!dr?.success) continue;
|
||
dr.data.filter(d=>d.type==='subdomain').forEach(d=>rows.push({...d,acct_username:acct.username}));
|
||
}
|
||
if (!rows.length) { el.innerHTML='<div class="empty">No subdomains found.</div>'; return; }
|
||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Subdomain</th><th>SSL</th><th>Created</th></tr></thead><tbody>
|
||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||
<td>${d.ssl_enabled?Nova.badge('SSL','green'):'—'}</td>
|
||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||
</tbody></table>`;
|
||
};
|
||
|
||
// ══ ADMIN PARKED DOMAINS PAGE ═════════════════════════════════════════════
|
||
window.adminParked = async function() {
|
||
document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active'));
|
||
document.querySelector('[data-page="parked-domains"]')?.classList.add('active');
|
||
document.getElementById('page-title').textContent = 'Parked Domains';
|
||
document.getElementById('page-content').innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||
<p class="text-muted" style="margin:0">All parked/alias domains across all accounts.</p>
|
||
</div>
|
||
<div class="card"><div id="admin-park-list"><div class="loading">Loading…</div></div></div>`;
|
||
const el = document.getElementById('admin-park-list');
|
||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||
let rows = [];
|
||
for (const acct of res.data) {
|
||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||
if (!dr?.success) continue;
|
||
const main = dr.data.find(d=>d.type==='main');
|
||
dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d=>rows.push({...d,acct_username:acct.username,main_domain:main?.domain||acct.domain}));
|
||
}
|
||
if (!rows.length) { el.innerHTML='<div class="empty">No parked domains found.</div>'; return; }
|
||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Parked Domain</th><th>Points To</th><th>Created</th></tr></thead><tbody>
|
||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||
<td style="color:var(--text-muted);font-size:.82rem">${d.main_domain}</td>
|
||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||
</tbody></table>`;
|
||
};
|
||
|