mirror of
https://github.com/myronblair/novacpx
synced 2026-07-28 05:03:14 -05:00
feat: NovaCPX v1.0.0 initial scaffold
Full hosting control panel with 3 tiers: Admin, Reseller, User. - install.sh: unattended installer for Ubuntu 20/22/24 + Debian 11/12 - PHP multi-version (7.4/8.1/8.2/8.3), Apache2/nginx choice, MySQL, PostgreSQL - BIND9 DNS, Postfix+Dovecot mail, ProFTPD, Certbot SSL, UFW, Fail2Ban - 18-table DB schema with audit log and version tracking - PHP REST API (auth, system/updates, server stats, service control) - Admin panel: dark dashboard, service manager, git-based update system - User panel: usage rings + feature card grid (distinct from cPanel) - VERSION file: git-tracked; Admin > Updates panel shows/applies git commits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
/* NovaCPX Design System */
|
||||
:root {
|
||||
--bg: #0d0f17;
|
||||
--bg2: #131520;
|
||||
--bg3: #1a1d2e;
|
||||
--border: #252840;
|
||||
--text: #e2e4f0;
|
||||
--text-muted: #7c7f9a;
|
||||
--primary: #6366f1;
|
||||
--primary-h: #4f52e8;
|
||||
--sky: #0ea5e9;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--radius: 10px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,.4);
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { font-size: 15px; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Login Page ─────────────────────────────────────────────────────────────── */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,.15) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(14,165,233,.1) 0%, transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.login-wrap { width: 100%; max-width: 420px; padding: 1.5rem; }
|
||||
|
||||
.login-brand {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
justify-content: center; margin-bottom: 2rem;
|
||||
}
|
||||
.logo-icon { width: 42px; height: 42px; }
|
||||
.logo-text { font-size: 1.8rem; font-weight: 300; letter-spacing: -.5px; }
|
||||
.logo-text strong { font-weight: 700; background: linear-gradient(135deg, #6366f1, #0ea5e9); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
.login-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.login-card h1 { font-size: 1.4rem; margin-bottom: .25rem; }
|
||||
.login-sub { color: var(--text-muted); font-size: .875rem; margin-bottom: 1.5rem; }
|
||||
|
||||
.login-footer {
|
||||
text-align: center; margin-top: 1.25rem;
|
||||
font-size: .8rem; color: var(--text-muted);
|
||||
}
|
||||
.login-footer a { color: var(--primary); text-decoration: none; }
|
||||
|
||||
/* ── Forms ──────────────────────────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-muted); }
|
||||
|
||||
input[type="text"], input[type="password"], input[type="email"],
|
||||
input[type="number"], input[type="url"], select, textarea {
|
||||
width: 100%; padding: .65rem .9rem;
|
||||
background: var(--bg3); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text);
|
||||
font-family: var(--font); font-size: .9rem;
|
||||
transition: border-color .15s;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--primary); }
|
||||
|
||||
.input-with-icon { position: relative; }
|
||||
.input-with-icon input { padding-right: 2.5rem; }
|
||||
.eye-toggle {
|
||||
position: absolute; right: .75rem; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer; color: var(--text-muted);
|
||||
padding: 0; display: flex; align-items: center;
|
||||
}
|
||||
.eye-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
padding: .6rem 1.25rem; border: none; border-radius: var(--radius);
|
||||
font-family: var(--font); font-size: .9rem; font-weight: 500;
|
||||
cursor: pointer; transition: all .15s; text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-h); }
|
||||
.btn-sky { background: var(--sky); color: #fff; }
|
||||
.btn-green { background: var(--green); color: #fff; }
|
||||
.btn-red { background: var(--red); color: #fff; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-full { width: 100%; justify-content: center; padding: .75rem; }
|
||||
.btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-sm { padding: .35rem .8rem; font-size: .82rem; }
|
||||
.btn-icon { padding: .5rem; border-radius: 8px; }
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────────────── */
|
||||
.alert { padding: .75rem 1rem; border-radius: var(--radius); font-size: .875rem; margin-bottom: 1rem; }
|
||||
.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #fca5a5; }
|
||||
.alert-success { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6ee7b7; }
|
||||
.alert-warning { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.3); color: #fcd34d; }
|
||||
.alert-info { background: rgba(99,102,241,.12); border: 1px solid rgba(99,102,241,.3); color: #a5b4fc; }
|
||||
|
||||
/* ── Panel Layout ────────────────────────────────────────────────────────────── */
|
||||
.panel-layout { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 240px; min-width: 240px; background: var(--bg2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: fixed; height: 100vh; overflow-y: auto; z-index: 100;
|
||||
}
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-brand .logo-text { font-size: 1.1rem; }
|
||||
.sidebar-brand .logo-icon { width: 28px; height: 28px; }
|
||||
|
||||
.sidebar-section { padding: .75rem 0; }
|
||||
.sidebar-section-label {
|
||||
font-size: .7rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--text-muted);
|
||||
padding: .25rem 1.25rem .5rem;
|
||||
}
|
||||
.sidebar-link {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
padding: .55rem 1.25rem; text-decoration: none;
|
||||
color: var(--text-muted); font-size: .88rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all .12s;
|
||||
}
|
||||
.sidebar-link:hover { color: var(--text); background: var(--bg3); }
|
||||
.sidebar-link.active { color: var(--primary); background: rgba(99,102,241,.1); border-left-color: var(--primary); }
|
||||
.sidebar-link svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
.sidebar-user {
|
||||
margin-top: auto; padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-user-info { display: flex; align-items: center; gap: .75rem; }
|
||||
.avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary), var(--sky));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: .9rem; flex-shrink: 0;
|
||||
}
|
||||
.user-name { font-size: .88rem; font-weight: 600; }
|
||||
.user-role { font-size: .75rem; color: var(--text-muted); text-transform: capitalize; }
|
||||
|
||||
.main-content { margin-left: 240px; flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.topbar {
|
||||
background: var(--bg2); border-bottom: 1px solid var(--border);
|
||||
padding: .75rem 1.5rem; display: flex; align-items: center;
|
||||
gap: 1rem; position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.topbar-title { font-size: 1rem; font-weight: 600; flex: 1; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: .5rem; }
|
||||
|
||||
.page-content { padding: 1.5rem; flex: 1; }
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
}
|
||||
.card-title { font-size: .95rem; font-weight: 600; flex: 1; }
|
||||
.card-body { padding: 1.25rem; }
|
||||
|
||||
/* ── Stats Cards ─────────────────────────────────────────────────────────────── */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.stat-card {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 1.25rem;
|
||||
}
|
||||
.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin-bottom: .5rem; }
|
||||
.stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1; }
|
||||
.stat-sub { font-size: .78rem; color: var(--text-muted); margin-top: .3rem; }
|
||||
.stat-green { color: var(--green); }
|
||||
.stat-red { color: var(--red); }
|
||||
.stat-yellow{ color: var(--yellow); }
|
||||
.stat-blue { color: var(--sky); }
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────────── */
|
||||
.progress { background: var(--bg3); border-radius: 999px; height: 6px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; border-radius: 999px; transition: width .3s; }
|
||||
.progress-bar.green { background: var(--green); }
|
||||
.progress-bar.yellow { background: var(--yellow); }
|
||||
.progress-bar.red { background: var(--red); }
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg3); }
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────────────────── */
|
||||
.badge { display: inline-block; padding: .2rem .55rem; border-radius: 999px; font-size: .72rem; font-weight: 600; }
|
||||
.badge-green { background: rgba(16,185,129,.15); color: #6ee7b7; }
|
||||
.badge-red { background: rgba(239,68,68,.15); color: #fca5a5; }
|
||||
.badge-yellow { background: rgba(245,158,11,.15); color: #fcd34d; }
|
||||
.badge-blue { background: rgba(99,102,241,.15); color: #a5b4fc; }
|
||||
.badge-sky { background: rgba(14,165,233,.15); color: #7dd3fc; }
|
||||
.badge-gray { background: rgba(148,163,184,.15); color: #94a3b8; }
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.7); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 14px; width: 100%; max-width: 500px;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.6);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.modal-title { font-size: 1rem; font-weight: 600; flex: 1; }
|
||||
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.25rem; }
|
||||
.modal-body { padding: 1.5rem; }
|
||||
.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
|
||||
.tab-btn {
|
||||
padding: .65rem 1.25rem; border: none; background: none;
|
||||
color: var(--text-muted); font-size: .88rem; cursor: pointer;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
transition: color .12s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
|
||||
/* ── Grid helpers ────────────────────────────────────────────────────────────── */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; }
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { transform: translateX(-100%); transition: transform .2s; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.main-content { margin-left: 0; }
|
||||
.grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Services status ─────────────────────────────────────────────────────────── */
|
||||
.service-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.service-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.service-dot.inactive { background: var(--red); }
|
||||
.service-dot.unknown { background: var(--text-muted); }
|
||||
|
||||
/* ── Code / Terminal ─────────────────────────────────────────────────────────── */
|
||||
code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em; background: var(--bg3); padding: .15em .4em; border-radius: 4px; }
|
||||
.terminal {
|
||||
background: #050508; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 1rem; font-family: monospace; font-size: .82rem; line-height: 1.7;
|
||||
color: #a6e22e; max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ───────────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────────────────────────── */
|
||||
.flex { display: flex; } .items-center { align-items: center; } .justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; } .gap-3 { gap: 1.5rem; }
|
||||
.mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; }
|
||||
.text-muted { color: var(--text-muted); } .text-sm { font-size: .82rem; }
|
||||
.text-right { text-align: right; } .font-bold { font-weight: 700; }
|
||||
.w-full { width: 100%; } .hidden { display: none; }
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* NovaCPX Admin Panel — page controllers
|
||||
*/
|
||||
(async () => {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────────
|
||||
const me = await Nova.api('auth', 'me');
|
||||
if (!me?.success || me.data.role !== 'admin') {
|
||||
location.href = '/?redirect=/admin/';
|
||||
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': serverStatus,
|
||||
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,
|
||||
'ssl-manager': sslManager,
|
||||
firewall,
|
||||
'audit-log': auditLog,
|
||||
updates,
|
||||
backups,
|
||||
settings,
|
||||
};
|
||||
|
||||
Nova.initNav(pages);
|
||||
await Nova.loadPage('dashboard', pages);
|
||||
checkUpdates();
|
||||
|
||||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
async function dashboard() {
|
||||
const [stats, version] = await Promise.all([
|
||||
Nova.api('system', 'stats'),
|
||||
Nova.api('system', 'version'),
|
||||
]);
|
||||
const s = stats?.data || {};
|
||||
const v = version?.data || {};
|
||||
|
||||
document.getElementById('server-ip').textContent = '';
|
||||
|
||||
return `
|
||||
<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>${Nova.serviceDot(status)} ${svc}</td>
|
||||
<td>${Nova.badge(status, status === 'active' ? 'green' : 'red')}</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>`;
|
||||
}
|
||||
|
||||
// ── Server Status ──────────────────────────────────────────────────────────
|
||||
async function serverStatus() {
|
||||
const res = await Nova.api('system', 'stats');
|
||||
const s = res?.data || {};
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Real-Time Server Status</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="adminPage('server-status')">↻ Refresh</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-3">
|
||||
<div><p class="text-muted text-sm mb-1">CPU</p><h2>${s.cpu?.pct}%</h2>${Nova.progressBar(s.cpu?.pct||0)}</div>
|
||||
<div><p class="text-muted text-sm mb-1">RAM</p><h2>${s.ram?.pct}%</h2>${Nova.progressBar(s.ram?.pct||0)}</div>
|
||||
<div><p class="text-muted text-sm mb-1">Disk</p><h2>${s.disk?.pct}%</h2>${Nova.progressBar(s.disk?.pct||0)}</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<p class="text-muted text-sm mb-1">Load Average</p>
|
||||
<p>${(s.cpu?.load||[]).join(' / ')}</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<p class="text-muted text-sm mb-1">Uptime</p>
|
||||
<p>${s.uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Updates ────────────────────────────────────────────────────────────────
|
||||
async function updates() {
|
||||
const [ver, check] = await Promise.all([
|
||||
Nova.api('system', 'version'),
|
||||
Nova.api('system', 'check-update'),
|
||||
]);
|
||||
const v = ver?.data || {};
|
||||
const upd = check?.data || {};
|
||||
const count = upd.updates_available || 0;
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">NovaCPX Updates</span>
|
||||
${count > 0 ? Nova.badge(count + ' update' + (count > 1 ? 's' : '') + ' available', 'yellow') : Nova.badge('Up to date', 'green')}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-2 mb-3">
|
||||
<div><p class="text-muted text-sm">Installed Version</p><p class="font-bold">${v.installed_version}</p></div>
|
||||
<div><p class="text-muted text-sm">Git Commit</p><code>${v.git_commit || '—'}</code></div>
|
||||
<div><p class="text-muted text-sm">Branch</p><code>${v.git_branch || 'main'}</code></div>
|
||||
<div><p class="text-muted text-sm">Dirty Working Tree</p><p>${v.git_dirty ? Nova.badge('Yes','yellow') : Nova.badge('No','green')}</p></div>
|
||||
</div>
|
||||
|
||||
${count > 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">
|
||||
${upd.commits?.map(c => `<div>${c}</div>`).join('') || 'None'}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="applyUpdate()">Apply Update</button>
|
||||
` : `<p class="text-muted">NovaCPX is up to date.</p>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Audit Log ──────────────────────────────────────────────────────────────
|
||||
async function auditLog() {
|
||||
const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } });
|
||||
const rows = res?.data || [];
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Audit Log</span></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Resource</th><th>IP</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map(r => `
|
||||
<tr>
|
||||
<td class="text-muted text-sm">${Nova.relTime(r.created_at)}</td>
|
||||
<td>${r.username || '—'}</td>
|
||||
<td><code>${r.action}</code></td>
|
||||
<td>${r.resource || '—'}</td>
|
||||
<td class="text-muted text-sm">${r.ip_address || '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── PHP Manager ────────────────────────────────────────────────────────────
|
||||
async function phpManager() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">PHP Version Manager</span></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-2">Manage installed PHP versions and global extensions.</p>
|
||||
<div class="grid-4">
|
||||
${['7.4','8.1','8.2','8.3'].map(v => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">PHP ${v}</div>
|
||||
<div class="stat-value" style="font-size:1rem">${Nova.badge('Active','green')}</div>
|
||||
<div class="mt-2 flex gap-1">
|
||||
<button class="btn btn-ghost btn-sm" onclick="phpAction('${v}','fpm-restart')">Restart FPM</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h4 class="mb-1">Global PHP Extensions</h4>
|
||||
<p class="text-muted text-sm">Extensions installed across all PHP versions: mbstring, curl, gd, xml, zip, opcache, redis, imagick, pdo, pdo_mysql, pdo_pgsql</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
async function settings() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Panel Settings</span></div>
|
||||
<div class="card-body">
|
||||
<form id="settings-form">
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Panel Name</label><input type="text" name="panel_name" value="NovaCPX"></div>
|
||||
<div class="form-group"><label>Default PHP Version</label>
|
||||
<select name="default_php">
|
||||
${['7.4','8.1','8.2','8.3'].map(v => `<option value="${v}" ${v==='8.3'?'selected':''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Primary Nameserver</label><input type="text" name="default_nameserver1" value="ns1.example.com"></div>
|
||||
<div class="form-group"><label>Secondary Nameserver</label><input type="text" name="default_nameserver2" value="ns2.example.com"></div>
|
||||
<div class="form-group"><label>Update Channel</label>
|
||||
<select name="update_channel"><option value="stable">Stable</option><option value="beta">Beta</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Git Remote</label><input type="url" name="git_remote" value="https://github.com/myronblair/novacpx.git"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Stub pages ─────────────────────────────────────────────────────────────
|
||||
function stubPage(title, desc) {
|
||||
return `<div class="card"><div class="card-header"><span class="card-title">${title}</span></div>
|
||||
<div class="card-body"><p class="text-muted">${desc}</p>
|
||||
<div class="mt-2">${Nova.badge('Coming Soon','yellow')}</div></div></div>`;
|
||||
}
|
||||
function accounts() { return stubPage('All Accounts', 'View and manage all hosting accounts on this server.'); }
|
||||
function resellers() { return stubPage('Resellers', 'Create and manage reseller accounts with custom packages and resource limits.'); }
|
||||
function packages() { return stubPage('Packages', 'Define hosting packages with disk, bandwidth, email, FTP, and database limits.'); }
|
||||
function createAccount() { return stubPage('Create Account', 'Create a new hosting account and assign it a package.'); }
|
||||
function dnsZones() { return stubPage('DNS Zones', 'View, add, and edit all DNS zones on this nameserver.'); }
|
||||
function nameservers() { return stubPage('Nameservers', 'Configure primary and secondary nameservers for all hosted domains.'); }
|
||||
function webServer() { return stubPage('Web Server', 'Manage Apache2 / nginx virtual hosts, modules, and configuration.'); }
|
||||
function mysqlManager() { return stubPage('MySQL / PostgreSQL', 'Create databases, users, and manage remote access.'); }
|
||||
function mailServer() { return stubPage('Mail Server', 'Manage Postfix/Dovecot configuration, spam filters, and mail queues.'); }
|
||||
function ftpServer() { return stubPage('FTP Server', 'Configure ProFTPD, manage FTP accounts and access rules.'); }
|
||||
function sslManager() { return stubPage('SSL Manager', 'Issue, install, and auto-renew Let\'s Encrypt SSL certificates for all domains.'); }
|
||||
function firewall() { return stubPage('Firewall / Fail2Ban', 'Manage UFW rules and review Fail2Ban bans.'); }
|
||||
function backups() { return stubPage('Backups', 'Configure automated backups, restore accounts, and manage backup storage.'); }
|
||||
|
||||
// ── Global action helpers ──────────────────────────────────────────────────
|
||||
window.adminPage = (page) => Nova.loadPage(page, pages);
|
||||
window.applyUpdate = async () => {
|
||||
Nova.confirm('Apply all pending updates? The panel may restart.', async () => {
|
||||
Nova.toast('Applying update…', 'info', 8000);
|
||||
const res = await Nova.api('system', 'apply-update', { method: 'POST' });
|
||||
if (res?.data?.updated) {
|
||||
Nova.toast(`Updated to ${res.data.to_commit}`, 'success');
|
||||
Nova.loadPage('updates', pages);
|
||||
} else {
|
||||
Nova.toast(res?.data?.pull_output || 'Already up to date', 'info');
|
||||
}
|
||||
});
|
||||
};
|
||||
window.adminServiceAction = async (svc, cmd) => {
|
||||
const res = await Nova.api('system', 'service', { method: 'POST', body: { service: svc, command: cmd } });
|
||||
Nova.toast(`${svc}: ${cmd} → ${res?.success ? 'OK' : res?.message}`, res?.success ? 'success' : 'error');
|
||||
};
|
||||
window.phpAction = async (ver, cmd) => {
|
||||
const svc = `php${ver}-fpm`;
|
||||
await window.adminServiceAction(svc, 'restart');
|
||||
};
|
||||
|
||||
// ── Check for updates badge ────────────────────────────────────────────────
|
||||
async function checkUpdates() {
|
||||
const res = await Nova.api('system', 'check-update');
|
||||
const n = res?.data?.updates_available || 0;
|
||||
const badge = document.getElementById('update-badge');
|
||||
if (badge && n > 0) { badge.textContent = n; badge.style.display = ''; }
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* NovaCPX — Shared JS utilities
|
||||
*/
|
||||
|
||||
window.Nova = (() => {
|
||||
// ── API ───────────────────────────────────────────────────────────────────
|
||||
async function api(endpoint, action, opts = {}) {
|
||||
const { method = 'GET', body, params } = opts;
|
||||
let url = `/api/${endpoint}/${action}`;
|
||||
if (params) url += '?' + new URLSearchParams(params);
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) { location.href = '/?redirect=' + encodeURIComponent(location.pathname); return null; }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────
|
||||
let toastEl = null;
|
||||
function toast(msg, type = 'info', duration = 3500) {
|
||||
if (!toastEl) {
|
||||
toastEl = document.createElement('div');
|
||||
toastEl.style.cssText = 'position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;max-width:380px';
|
||||
document.body.appendChild(toastEl);
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = `alert alert-${type}`;
|
||||
el.style.cssText = 'animation:fadeIn .2s;cursor:pointer;box-shadow:var(--shadow)';
|
||||
el.textContent = msg;
|
||||
el.addEventListener('click', () => el.remove());
|
||||
toastEl.appendChild(el);
|
||||
setTimeout(() => el.remove(), duration);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────
|
||||
function modal(title, bodyHtml, footerHtml = '') {
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'modal-overlay open';
|
||||
ov.innerHTML = `<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">${title}</span>
|
||||
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">${bodyHtml}</div>
|
||||
${footerHtml ? `<div class="modal-footer">${footerHtml}</div>` : ''}
|
||||
</div>`;
|
||||
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
|
||||
document.body.appendChild(ov);
|
||||
return ov;
|
||||
}
|
||||
|
||||
// ── Confirm dialog ────────────────────────────────────────────────────────
|
||||
function confirm(msg, onYes, danger = false) {
|
||||
const ov = modal('Confirm', `<p>${msg}</p>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-${danger ? 'red' : 'primary'}" id="confirm-yes">Confirm</button>`
|
||||
);
|
||||
ov.querySelector('#confirm-yes').onclick = () => { ov.remove(); onYes(); };
|
||||
}
|
||||
|
||||
// ── Sidebar navigation ────────────────────────────────────────────────────
|
||||
function initNav(pages) {
|
||||
document.querySelectorAll('[data-page]').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const page = link.dataset.page;
|
||||
document.querySelectorAll('[data-page]').forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl) titleEl.textContent = link.textContent.trim();
|
||||
loadPage(page, pages);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadPage(page, pages) {
|
||||
const content = document.getElementById('page-content');
|
||||
if (!content) return;
|
||||
const fn = pages[page];
|
||||
if (fn) {
|
||||
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
|
||||
Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; });
|
||||
} else {
|
||||
content.innerHTML = `<div class="card"><div class="card-body"><p class="text-muted">Page "${page}" coming soon.</p></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress bar helper ───────────────────────────────────────────────────
|
||||
function progressBar(pct) {
|
||||
const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green';
|
||||
return `<div class="progress"><div class="progress-bar ${color}" style="width:${pct}%"></div></div>`;
|
||||
}
|
||||
|
||||
// ── Format helpers ────────────────────────────────────────────────────────
|
||||
function bytes(n) {
|
||||
if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB';
|
||||
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
|
||||
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return n + ' B';
|
||||
}
|
||||
function relTime(dateStr) {
|
||||
const diff = (Date.now() - new Date(dateStr)) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
function badge(text, type = 'blue') {
|
||||
return `<span class="badge badge-${type}">${text}</span>`;
|
||||
}
|
||||
function serviceDot(status) {
|
||||
const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown';
|
||||
return `<span class="service-dot ${cls}"></span>`;
|
||||
}
|
||||
|
||||
// Inject global CSS animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}';
|
||||
document.head.appendChild(style);
|
||||
|
||||
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot };
|
||||
})();
|
||||
Reference in New Issue
Block a user