mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
7c17e3696d
- DockerManager.php: install Docker CE, engine status, container lifecycle (run/stop/start/restart/remove/logs/inspect), image management (pull/list/remove), volumes, networks, compose stacks, per-user quotas, app catalog with 9 one-click templates - docker.php API endpoint covering all operations with role-based access control (admin/reseller/user isolation) - DB migration 006: docker_containers, docker_compose_stacks, docker_quotas tables - Admin panel: Docker sidebar link + full management page (containers, images, volumes, networks, compose stacks, quota editor) - Reseller panel: Docker tab with customer container view, quota management, and app catalog deployment for customers - User panel: Docker tab with container dashboard, quota display, and self-service app catalog (9 apps: WP, Ghost, Nextcloud, Gitea, Matomo, Vaultwarden, Node.js, Flask, Static) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
485 lines
28 KiB
JavaScript
485 lines
28 KiB
JavaScript
/**
|
|
* NovaCPX Reseller Panel JS
|
|
*/
|
|
|
|
let _rUser = null;
|
|
|
|
async function initReseller() {
|
|
const res = await Nova.api('auth', 'me');
|
|
if (!res?.success || !['admin','reseller'].includes(res.data?.role)) {
|
|
document.getElementById('auth-check').innerHTML = renderLogin();
|
|
document.getElementById('main-layout').style.display = 'none';
|
|
return false;
|
|
}
|
|
_rUser = res.data;
|
|
document.getElementById('user-name').textContent = _rUser.username || 'Reseller';
|
|
return true;
|
|
}
|
|
|
|
function renderLogin() {
|
|
return `<div class="login-wrap">
|
|
<div class="login-card">
|
|
<div style="text-align:center;margin-bottom:2rem">
|
|
<img src="/assets/img/nova-logo.svg" style="height:42px;margin-bottom:.5rem">
|
|
<div style="color:var(--muted);font-size:.85rem">Reseller Portal · Port 8881</div>
|
|
</div>
|
|
<div class="form-group"><label class="form-label">Username</label><input id="li-user" type="text" class="form-control" autocomplete="username"></div>
|
|
<div class="form-group"><label class="form-label">Password</label><input id="li-pass" type="password" class="form-control" autocomplete="current-password"></div>
|
|
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Sign In</button>
|
|
<div id="li-err" style="color:var(--red);margin-top:.75rem;text-align:center;display:none"></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
async function doLogin() {
|
|
const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }});
|
|
if (res?.success) {
|
|
if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url;
|
|
else location.reload();
|
|
} else {
|
|
const err = document.getElementById('li-err');
|
|
if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; }
|
|
}
|
|
}
|
|
window.doLogin = doLogin;
|
|
|
|
/* ── Pages ─────────────────────────────────────────────────────────────── */
|
|
|
|
async function rDashboard(el) {
|
|
el.innerHTML = `<div class="page-header"><h2 class="page-title">Reseller Dashboard</h2></div>
|
|
<div id="r-stats" class="stats-grid"><div class="loading">Loading…</div></div>
|
|
<div style="margin-top:1.5rem" class="card">
|
|
<div class="card-header"><span class="card-title">Recent Accounts</span>
|
|
<button class="btn btn-sm btn-primary" onclick="resellerNav('accounts')">View All</button>
|
|
</div>
|
|
<div id="r-recent"><div class="loading">Loading…</div></div>
|
|
</div>`;
|
|
|
|
const res = await Nova.api('accounts', 'list', { params:{ limit:5 }});
|
|
const accts = res?.data?.accounts || [];
|
|
|
|
document.getElementById('r-stats').innerHTML = [
|
|
{ label: 'Total Accounts', val: res?.data?.total || 0, icon: 'ni-accounts' },
|
|
{ label: 'Active', val: accts.filter(a=>a.status==='active').length, icon: 'ni-stats' },
|
|
{ label: 'Suspended', val: accts.filter(a=>a.status==='suspended').length, icon: 'ni-suspend' },
|
|
].map(s => `<div class="stat-card" style="display:flex;align-items:center;gap:1rem">
|
|
<svg width="32" height="32" style="color:var(--primary);flex-shrink:0"><use href="/assets/img/nova-icons.svg#${s.icon}"/></svg>
|
|
<div><div class="stat-value">${s.val}</div><div class="stat-label">${s.label}</div></div>
|
|
</div>`).join('');
|
|
|
|
document.getElementById('r-recent').innerHTML = accts.length
|
|
? `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Status</th></tr></thead><tbody>
|
|
${accts.map(a => `<tr>
|
|
<td>${a.username}</td><td>${a.domain}</td><td>${a.package_name||'—'}</td>
|
|
<td>${Nova.badge(a.status, a.status==='active'?'green':'yellow')}</td>
|
|
</tr>`).join('')}
|
|
</tbody></table>`
|
|
: '<div class="empty">No accounts yet.</div>';
|
|
}
|
|
|
|
async function rAccounts(el) {
|
|
el.innerHTML = `<div class="page-header">
|
|
<h2 class="page-title">Hosting Accounts</h2>
|
|
<button class="btn btn-primary btn-sm" onclick="resellerNav('createAccount')">+ Create Account</button>
|
|
</div>
|
|
<div class="card">
|
|
<div style="padding:.75rem;border-bottom:1px solid var(--border)">
|
|
<input id="r-search" class="form-control" placeholder="Search accounts…" oninput="rSearchAccounts(this.value)" style="max-width:300px">
|
|
</div>
|
|
<div id="r-accounts-list"><div class="loading">Loading…</div></div>
|
|
</div>`;
|
|
loadRAccounts();
|
|
}
|
|
|
|
async function loadRAccounts(search = '') {
|
|
const el = document.getElementById('r-accounts-list');
|
|
if (!el) return;
|
|
const res = await Nova.api('accounts', 'list', { params: search ? { search } : {}});
|
|
if (!res?.success || !res.data.accounts.length) { el.innerHTML = '<div class="empty">No accounts found.</div>'; return; }
|
|
el.innerHTML = `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Disk</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
|
${res.data.accounts.map(a => `<tr>
|
|
<td><strong>${a.username}</strong></td>
|
|
<td>${a.domain}</td>
|
|
<td>${a.package_name || '—'}</td>
|
|
<td>${a.disk_usage_mb || 0} MB</td>
|
|
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
|
<td style="display:flex;gap:.25rem">
|
|
${a.status === 'active'
|
|
? `<button class="btn btn-xs btn-warning" onclick="rSuspend(${a.id},'${a.username}')">Suspend</button>`
|
|
: `<button class="btn btn-xs btn-success" onclick="rUnsuspend(${a.id},'${a.username}')">Unsuspend</button>`}
|
|
<button class="btn btn-xs" onclick="rChangePass(${a.id},'${a.username}')">Passwd</button>
|
|
<button class="btn btn-xs btn-danger" onclick="rTerminate(${a.id},'${a.username}')">Terminate</button>
|
|
</td>
|
|
</tr>`).join('')}
|
|
</tbody></table>`;
|
|
}
|
|
window.loadRAccounts = loadRAccounts;
|
|
window.rSearchAccounts = (v) => loadRAccounts(v);
|
|
|
|
window.rSuspend = async (id, user) => {
|
|
Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => {
|
|
const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }});
|
|
if (res?.success) { Nova.toast('Account suspended','success'); loadRAccounts(); }
|
|
else Nova.toast(res?.message,'error');
|
|
});
|
|
};
|
|
window.rUnsuspend = async (id, user) => {
|
|
const res = await Nova.api('accounts', 'unsuspend', { method:'POST', body:{ account_id: id }});
|
|
if (res?.success) { Nova.toast('Account unsuspended','success'); loadRAccounts(); }
|
|
else Nova.toast(res?.message,'error');
|
|
};
|
|
window.rTerminate = (id, user) => {
|
|
Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, databases, DNS, and email. THIS CANNOT BE UNDONE.`, async () => {
|
|
const res = await Nova.api('accounts', 'terminate', { method:'POST', body:{ account_id: id }});
|
|
if (res?.success) { Nova.toast('Account terminated','success'); loadRAccounts(); }
|
|
else Nova.toast(res?.message,'error');
|
|
}, true);
|
|
};
|
|
window.rChangePass = (id, user) => {
|
|
Nova.modal(`Change Password — ${user}`, `<div class="form-group"><label class="form-label">New Password</label><input id="rp-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('rp-pass').value}}).then(r=>{if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Update</button>`);
|
|
};
|
|
|
|
async function rCreateAccount(el) {
|
|
el.innerHTML = `<div class="page-header"><h2 class="page-title">Create Hosting Account</h2></div>
|
|
<div class="card" style="max-width:600px">
|
|
<div style="padding:1.5rem">
|
|
<div class="form-group"><label class="form-label">Username <span style="color:var(--red)">*</span></label><input id="ca-user" class="form-control" placeholder="lowercase letters, numbers"></div>
|
|
<div class="form-group"><label class="form-label">Password <span style="color:var(--red)">*</span></label><input id="ca-pass" type="password" class="form-control"></div>
|
|
<div class="form-group"><label class="form-label">Email</label><input id="ca-email" type="email" class="form-control" placeholder="user@example.com"></div>
|
|
<div class="form-group"><label class="form-label">Primary Domain <span style="color:var(--red)">*</span></label><input id="ca-domain" class="form-control" placeholder="example.com"></div>
|
|
<div class="form-group"><label class="form-label">Package</label><select id="ca-pkg" class="form-control"><option value="">Loading…</option></select></div>
|
|
<div style="margin-top:1.5rem;display:flex;gap:.75rem">
|
|
<button class="btn btn-primary" onclick="submitCreateAccount()">Create Account</button>
|
|
<button class="btn" onclick="resellerNav('accounts')">Cancel</button>
|
|
</div>
|
|
<div id="ca-result" style="margin-top:1rem"></div>
|
|
</div>
|
|
</div>`;
|
|
|
|
Nova.api('packages', 'list').then(res => {
|
|
const sel = document.getElementById('ca-pkg');
|
|
if (sel && res?.success) {
|
|
sel.innerHTML = res.data.map(p => `<option value="${p.id}">${p.name} — ${p.disk_mb}MB disk</option>`).join('');
|
|
}
|
|
});
|
|
}
|
|
|
|
window.submitCreateAccount = async () => {
|
|
const btn = document.querySelector('#ca-result');
|
|
if (btn) btn.textContent = '';
|
|
const res = await Nova.api('accounts', 'create', { method:'POST', body:{
|
|
username: document.getElementById('ca-user')?.value,
|
|
password: document.getElementById('ca-pass')?.value,
|
|
email: document.getElementById('ca-email')?.value,
|
|
domain: document.getElementById('ca-domain')?.value,
|
|
package_id: document.getElementById('ca-pkg')?.value,
|
|
}});
|
|
if (res?.success) {
|
|
Nova.toast('Account created successfully!','success');
|
|
if (btn) btn.innerHTML = `<div class="alert alert-success">Account created! <a href="#" onclick="resellerNav('accounts')">View accounts →</a></div>`;
|
|
} else {
|
|
Nova.toast(res?.message || 'Failed to create account','error');
|
|
if (btn) btn.innerHTML = `<div class="alert alert-error">${res?.message || 'Error'}</div>`;
|
|
}
|
|
};
|
|
|
|
async function rPackages(el) {
|
|
el.innerHTML = `<div class="page-header">
|
|
<h2 class="page-title">Packages</h2>
|
|
<button class="btn btn-primary btn-sm" onclick="rAddPackage()">+ Add Package</button>
|
|
</div>
|
|
<div class="card"><div id="pkg-list"><div class="loading">Loading…</div></div></div>`;
|
|
|
|
const res = await Nova.api('packages', 'list');
|
|
const plist = document.getElementById('pkg-list');
|
|
if (!res?.success || !res.data.length) { plist.innerHTML = '<div class="empty">No packages yet.</div>'; return; }
|
|
plist.innerHTML = `<table class="table"><thead><tr><th>Name</th><th>Disk</th><th>BW</th><th>DBs</th><th>Emails</th><th>Domains</th><th>Price</th><th>Actions</th></tr></thead><tbody>
|
|
${res.data.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.addon_domains || '∞'}</td>
|
|
<td>${p.price ? '$'+p.price : 'Free'}</td>
|
|
<td style="display:flex;gap:.25rem">
|
|
<button class="btn btn-xs" onclick="rEditPackage(${p.id})">Edit</button>
|
|
<button class="btn btn-xs btn-danger" onclick="rDeletePackage(${p.id},'${p.name}')">Del</button>
|
|
</td>
|
|
</tr>`).join('')}
|
|
</tbody></table>`;
|
|
}
|
|
|
|
window.rAddPackage = () => showPackageModal();
|
|
window.rEditPackage = async (id) => {
|
|
const res = await Nova.api('packages', 'get', { params:{ id }});
|
|
if (res?.success) showPackageModal(res.data);
|
|
};
|
|
function showPackageModal(pkg = null) {
|
|
const p = pkg || {};
|
|
Nova.modal(pkg ? '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">Package Name</label><input id="pk-name" class="form-control" value="${p.name||''}"></div>
|
|
<div class="form-group"><label class="form-label">Disk (MB, 0=∞)</label><input id="pk-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="pk-bw" type="number" class="form-control" value="${p.bandwidth_mb||0}"></div>
|
|
<div class="form-group"><label class="form-label">Databases</label><input id="pk-db" type="number" class="form-control" value="${p.databases||0}"></div>
|
|
<div class="form-group"><label class="form-label">Email Accounts</label><input id="pk-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="pk-adom" type="number" class="form-control" value="${p.addon_domains||0}"></div>
|
|
<div class="form-group"><label class="form-label">Subdomains</label><input id="pk-sub" type="number" class="form-control" value="${p.subdomains||0}"></div>
|
|
<div class="form-group"><label class="form-label">FTP Accounts</label><input id="pk-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="pk-price" type="number" step="0.01" class="form-control" value="${p.price||0}"></div>
|
|
</div>`,
|
|
`<button class="btn btn-primary" onclick="submitPackage(${p.id||'null'})">Save</button>`);
|
|
}
|
|
window.submitPackage = async (id) => {
|
|
const body = { name:document.getElementById('pk-name')?.value, disk_mb:parseInt(document.getElementById('pk-disk')?.value), bandwidth_mb:parseInt(document.getElementById('pk-bw')?.value), databases:parseInt(document.getElementById('pk-db')?.value), email_accounts:parseInt(document.getElementById('pk-email')?.value), addon_domains:parseInt(document.getElementById('pk-adom')?.value), subdomains:parseInt(document.getElementById('pk-sub')?.value), ftp_accounts:parseInt(document.getElementById('pk-ftp')?.value), price:parseFloat(document.getElementById('pk-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 ? 'Package updated' : 'Package created','success'); document.querySelector('.modal-overlay')?.remove(); rPackages(document.getElementById('page-content')); }
|
|
else Nova.toast(res?.message,'error');
|
|
};
|
|
window.rDeletePackage = (id, name) => {
|
|
Nova.confirm(`Delete package "${name}"? Cannot delete if accounts are using it.`, async () => {
|
|
const res = await Nova.api('packages','delete',{method:'POST',body:{id}});
|
|
if (res?.success) { Nova.toast('Deleted','success'); rPackages(document.getElementById('page-content')); }
|
|
else Nova.toast(res?.message,'error');
|
|
}, true);
|
|
};
|
|
|
|
async function rDNS(el) {
|
|
el.innerHTML = `<div class="page-header"><h2 class="page-title">DNS Zones</h2></div>
|
|
<div class="card"><div id="r-dns-list"><div class="loading">Loading…</div></div></div>`;
|
|
const res = await Nova.api('dns', 'zones');
|
|
const list = document.getElementById('r-dns-list');
|
|
if (!res?.success || !res.data.length) { list.innerHTML = '<div class="empty">No DNS zones.</div>'; return; }
|
|
list.innerHTML = `<table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Records</th><th>Actions</th></tr></thead><tbody>
|
|
${res.data.map(z => `<tr>
|
|
<td>${z.domain}</td>
|
|
<td>${z.username||'—'}</td>
|
|
<td>${z.record_count||0}</td>
|
|
<td><button class="btn btn-xs" onclick="rViewZone(${z.id},'${z.domain}')">Edit Records</button></td>
|
|
</tr>`).join('')}
|
|
</tbody></table>`;
|
|
}
|
|
|
|
window.rViewZone = async (zoneId, domain) => {
|
|
const res = await Nova.api('dns', 'records', { params:{ zone_id: zoneId }});
|
|
if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
|
|
const rows = res.data.map(r => `<tr>
|
|
<td>${r.name}</td><td>${Nova.badge(r.type,'default')}</td><td><code style="font-size:.8rem">${r.value}</code></td><td>${r.ttl}</td>
|
|
<td><button class="btn btn-xs btn-danger" onclick="rDeleteRecord(${r.id},${zoneId},'${domain}')">Del</button></td>
|
|
</tr>`).join('');
|
|
Nova.modal(`DNS Records — ${domain}`,
|
|
`<button class="btn btn-sm btn-primary" style="margin-bottom:.75rem" onclick="rAddRecord(${zoneId},'${domain}')">+ Add Record</button>
|
|
<table class="table"><thead><tr><th>Name</th><th>Type</th><th>Value</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`);
|
|
};
|
|
window.rAddRecord = (zoneId, domain) => {
|
|
Nova.modal('Add DNS Record', `
|
|
<div class="form-group"><label class="form-label">Name</label><input id="dns-name" class="form-control" placeholder="@ or subdomain"></div>
|
|
<div class="form-group"><label class="form-label">Type</label><select id="dns-type" class="form-control"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>NS</option><option>SRV</option></select></div>
|
|
<div class="form-group"><label class="form-label">Value</label><input id="dns-val" class="form-control"></div>
|
|
<div class="form-group"><label class="form-label">TTL</label><input id="dns-ttl" type="number" class="form-control" value="3600"></div>
|
|
<div class="form-group"><label class="form-label">Priority (MX)</label><input id="dns-pri" type="number" class="form-control" value="10"></div>`,
|
|
`<button class="btn btn-primary" onclick="Nova.api('dns','add-record',{method:'POST',body:{zone_id:${zoneId},name:document.getElementById('dns-name').value,type:document.getElementById('dns-type').value,value:document.getElementById('dns-val').value,ttl:parseInt(document.getElementById('dns-ttl').value),priority:parseInt(document.getElementById('dns-pri').value)}}).then(r=>{if(r?.success){Nova.toast('Record added','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Add Record</button>`);
|
|
};
|
|
window.rDeleteRecord = async (id, zoneId, domain) => {
|
|
Nova.confirm('Delete this DNS record?', async () => {
|
|
const res = await Nova.api('dns', 'delete-record', { method:'POST', body:{id, zone_id: zoneId }});
|
|
if (res?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); rViewZone(zoneId, domain); }
|
|
else Nova.toast(res?.message,'error');
|
|
}, true);
|
|
};
|
|
|
|
/* ── Nav ────────────────────────────────────────────────────────────────── */
|
|
const rNavItems = [
|
|
{ id:'dashboard', label:'Dashboard', icon:'ni-dashboard' },
|
|
{ id:'accounts', label:'Accounts', icon:'ni-accounts' },
|
|
{ id:'createAccount', label:'New Account', icon:'ni-add' },
|
|
{ id:'packages', label:'Packages', icon:'ni-packages' },
|
|
{ id:'dns', label:'DNS Zones', icon:'ni-dns' },
|
|
{ id:'docker', label:'Docker', icon:'ni-docker' },
|
|
];
|
|
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker };
|
|
|
|
let _rActivePage = 'dashboard';
|
|
|
|
function renderRNav() {
|
|
const nav = document.getElementById('sidebar-nav');
|
|
if (!nav) return;
|
|
nav.innerHTML = rNavItems.map(n => `
|
|
<a class="nav-item ${n.id === _rActivePage ? 'active' : ''}" href="#" onclick="resellerNav('${n.id}');return false">
|
|
<svg width="18" height="18"><use href="/assets/img/nova-icons.svg#${n.icon}"/></svg>
|
|
<span>${n.label}</span>
|
|
</a>`).join('');
|
|
}
|
|
|
|
window.resellerNav = (page) => {
|
|
_rActivePage = page;
|
|
renderRNav();
|
|
const content = document.getElementById('page-content');
|
|
if (!content) return;
|
|
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
if (rPages[page]) rPages[page](content);
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
const ok = await initReseller();
|
|
if (!ok) return;
|
|
renderRNav();
|
|
window.resellerNav('dashboard');
|
|
});
|
|
|
|
/* ── Docker (Reseller #33) ────────────────────────────────────────────────── */
|
|
async function rDocker(el) {
|
|
el.innerHTML = '<div class="loading">Loading…</div>';
|
|
const [stRes, acctRes] = await Promise.all([
|
|
Nova.api('docker', 'stacks'),
|
|
Nova.api('accounts', 'list', { params: { limit: 200 } }),
|
|
]);
|
|
const stacks = stRes?.data?.stacks || [];
|
|
const accts = acctRes?.data?.accounts || [];
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
|
<p class="text-muted" style="margin-bottom:1.5rem">Manage Docker containers and quotas for your customers. Contact the server admin to change your own Docker allocation.</p>
|
|
|
|
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
|
<button class="btn btn-sm ${_rDockerTab==='containers'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('containers')">Containers</button>
|
|
<button class="btn btn-sm ${_rDockerTab==='quotas'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('quotas')">Customer Quotas</button>
|
|
<button class="btn btn-sm ${_rDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('catalog')">App Catalog</button>
|
|
</div>
|
|
<div id="rdocker-content"><div class="loading">Loading…</div></div>`;
|
|
|
|
window._rDockerAccts = accts;
|
|
window._rDockerTab = window._rDockerTab || 'containers';
|
|
|
|
window.rDockerTab = async (tab) => {
|
|
window._rDockerTab = tab;
|
|
document.querySelectorAll('[onclick^="rDockerTab"]').forEach(b => {
|
|
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
|
|
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
|
|
});
|
|
await rDockerLoadTab(tab);
|
|
};
|
|
|
|
await rDockerLoadTab(window._rDockerTab);
|
|
}
|
|
|
|
window._rDockerTab = 'containers';
|
|
|
|
async function rDockerLoadTab(tab) {
|
|
const tc = document.getElementById('rdocker-content');
|
|
if (!tc) return;
|
|
tc.innerHTML = '<div class="loading">Loading…</div>';
|
|
|
|
if (tab === 'containers') {
|
|
const r = await Nova.api('docker', 'containers');
|
|
const rows = r?.data?.containers || [];
|
|
tc.innerHTML = rows.length === 0
|
|
? '<div class="text-muted" style="padding:2rem;text-align:center">No containers for your accounts</div>'
|
|
: `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Actions</th></tr></thead><tbody>
|
|
${rows.map(c=>`<tr>
|
|
<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':'red')}</td>
|
|
<td>${c.account_id||'—'}</td>
|
|
<td>
|
|
${c.status==='running'
|
|
? `<button class="btn btn-xs btn-warning" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>`
|
|
: `<button class="btn btn-xs btn-success" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
|
<button class="btn btn-xs btn-ghost" onclick="rDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
|
</td>
|
|
</tr>`).join('')}
|
|
</tbody></table></div>`;
|
|
|
|
} else if (tab === 'quotas') {
|
|
const accts = window._rDockerAccts || [];
|
|
tc.innerHTML = accts.length === 0
|
|
? '<div class="text-muted" style="padding:2rem;text-align:center">No accounts</div>'
|
|
: `<p class="text-muted" style="margin-bottom:1rem">Set Docker limits for each of your customers.</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>
|
|
${accts.map(u=>`<tr>
|
|
<td>${Nova.escHtml(u.username)}</td>
|
|
<td>2</td><td>512 MB</td><td>1.0</td>
|
|
<td><button class="btn btn-xs btn-primary" onclick="rDockerQuotaModal(${u.user_id},'${Nova.escHtml(u.username)}')">Edit</button></td>
|
|
</tr>`).join('')}
|
|
</tbody></table></div>`;
|
|
|
|
} else if (tab === 'catalog') {
|
|
const r = await Nova.api('docker', 'catalog');
|
|
const catalog = r?.data?.catalog || {};
|
|
const accts = window._rDockerAccts || [];
|
|
tc.innerHTML = `
|
|
<p class="text-muted" style="margin-bottom:1rem">Pre-install app stacks for your customers.</p>
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem">
|
|
${Object.entries(catalog).map(([key,app])=>`
|
|
<div class="card" style="cursor:pointer" onclick="rDockerLaunchModal('${key}','${Nova.escHtml(app.name)}')">
|
|
<div class="card-body" style="text-align:center;padding:1.5rem">
|
|
<div style="font-size:1.5rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
|
<div style="font-weight:600">${Nova.escHtml(app.name)}</div>
|
|
<div style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">${Nova.escHtml(app.description)}</div>
|
|
<button class="btn btn-sm btn-primary" style="margin-top:1rem">Deploy</button>
|
|
</div>
|
|
</div>`).join('')}
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
window.rDockerAct = async (cid, action) => {
|
|
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');
|
|
if (r?.success) rDockerLoadTab('containers');
|
|
};
|
|
|
|
window.rDockerLogs = async (cid, name) => {
|
|
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } });
|
|
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.logs||'')}</pre>`);
|
|
};
|
|
|
|
window.rDockerQuotaModal = (userId, username) => {
|
|
const ov = Nova.modal(`Docker Quota: ${username}`,
|
|
`<div class="form-group"><label>Max Containers</label><input id="rdq-cnt" type="number" class="form-control" value="2" min="0"></div>
|
|
<div class="form-group"><label>Max Memory (MB)</label><input id="rdq-mem" type="number" class="form-control" value="512" min="64"></div>
|
|
<div class="form-group"><label>Max CPUs</label><input id="rdq-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="rDockerQuotaSubmit(${userId})">Save</button>`
|
|
);
|
|
window.rDockerQuotaSubmit = async (uid) => {
|
|
ov.remove();
|
|
const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{
|
|
user_id: uid,
|
|
max_containers: parseInt(document.getElementById('rdq-cnt').value)||2,
|
|
max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512,
|
|
max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0,
|
|
}});
|
|
Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error');
|
|
};
|
|
};
|
|
|
|
window.rDockerLaunchModal = async (appKey, appName) => {
|
|
const catRes = await Nova.api('docker', 'catalog');
|
|
const app = catRes?.data?.catalog?.[appKey];
|
|
if (!app) return;
|
|
const accts = window._rDockerAccts || [];
|
|
const acctOpts = accts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)}</option>`).join('');
|
|
const paramFields = (app.params||[]).map(p=>`
|
|
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
|
<input id="rl-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
|
const ov = Nova.modal(`Deploy ${appName}`,
|
|
`<div class="form-group"><label>Account</label><select id="rl-acct" class="form-control"><option value="">Select account</option>${acctOpts}</select></div>${paramFields}`,
|
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="rDockerLaunchSubmit('${appKey}')">Deploy</button>`
|
|
);
|
|
window.rDockerLaunchSubmit = async (key) => {
|
|
const acctId = parseInt(document.getElementById('rl-acct').value)||0;
|
|
if (!acctId) { Nova.toast('Select an account','error'); return; }
|
|
const params = {};
|
|
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
|
|
ov.remove();
|
|
Nova.toast('Deploying…', 'info', 10000);
|
|
const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }});
|
|
Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error');
|
|
if (r?.success) rDockerLoadTab('containers');
|
|
};
|
|
};
|