mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: Docker tiered container management (#31-35)
- 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>
This commit is contained in:
@@ -90,6 +90,7 @@
|
||||
'nginx-proxy': nginxProxy,
|
||||
sessions,
|
||||
wordpress,
|
||||
docker,
|
||||
'ssl-manager': sslManager,
|
||||
firewall,
|
||||
'audit-log': auditLog,
|
||||
@@ -2236,3 +2237,293 @@ window.sessionsRevokeAll = () => {
|
||||
if(r?.success) setTimeout(()=>location.reload(),1500);
|
||||
},true);
|
||||
};
|
||||
|
||||
// ── #31-35 Docker Management ───────────────────────────────────────────────
|
||||
async function docker(el) {
|
||||
el.innerHTML = '<div style="padding:2rem;text-align:center;color:var(--text-muted)">Loading Docker status…</div>';
|
||||
const st = await Nova.api('docker', 'status');
|
||||
const status = st?.data || {};
|
||||
|
||||
if (!status.installed) {
|
||||
el.innerHTML = `
|
||||
<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.dockerInstall = async (btn) => {
|
||||
btn.disabled = true; btn.textContent = 'Installing… (this may take 2-3 minutes)';
|
||||
const r = await Nova.api('docker', 'install', { method: 'POST', body: {} });
|
||||
Nova.toast(r?.message || (r?.success ? 'Installed' : 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) Nova.loadPage('docker', window._novaPages);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = (id, label) => `<button class="btn btn-sm ${id===_dockerTab?'btn-primary':'btn-ghost'}" onclick="dockerTab('${id}')">${label}</button>`;
|
||||
window._dockerTab = window._dockerTab || 'containers';
|
||||
|
||||
el.innerHTML = `
|
||||
<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('quotas','User Quotas')}
|
||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||||
</div>
|
||||
<div id="docker-tab-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
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(_dockerTab);
|
||||
}, true);
|
||||
|
||||
await dockerLoadTab(window._dockerTab);
|
||||
}
|
||||
|
||||
async function dockerLoadTab(tab) {
|
||||
const tc = document.getElementById('docker-tab-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 = `
|
||||
<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>
|
||||
<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-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'quotas') {
|
||||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||
const users = r?.data?.accounts || [];
|
||||
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.dockerContainerAct = 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) dockerLoadTab('containers');
|
||||
};
|
||||
|
||||
window.dockerRemove = (cid) => Nova.confirm('Remove this container?', async () => {
|
||||
const r = await Nova.api('docker', 'container-remove', { method: 'DELETE', body: { container_id: cid, force: true } });
|
||||
Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('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?', async () => {
|
||||
const r = await Nova.api('docker', 'image-remove', { method: 'DELETE', body: { image_id: id } });
|
||||
Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('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.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');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -297,8 +297,9 @@ const rNavItems = [
|
||||
{ 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 };
|
||||
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker };
|
||||
|
||||
let _rActivePage = 'dashboard';
|
||||
|
||||
@@ -327,3 +328,157 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
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');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -69,6 +69,7 @@ const userPages = {
|
||||
files,
|
||||
stats: statsPage,
|
||||
backups,
|
||||
docker: dockerPage,
|
||||
'change-password': changePasswordPage,
|
||||
};
|
||||
|
||||
@@ -773,6 +774,7 @@ const navItems = [
|
||||
{ id: 'files', label: 'File Manager', icon: 'ni-files' },
|
||||
{ id: 'stats', label: 'Statistics', icon: 'ni-stats' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'ni-backups' },
|
||||
{ id: 'docker', label: 'Docker', icon: 'ni-docker' },
|
||||
{ id: 'change-password', label: 'Change Password', icon: 'ni-lock' },
|
||||
];
|
||||
|
||||
@@ -841,6 +843,167 @@ window.submitChangePassword = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Docker (#34) ────────────────────────────────────────────────────────── */
|
||||
async function dockerPage(el) {
|
||||
el.innerHTML = '<div class="loading">Loading Docker…</div>';
|
||||
const [contRes, quotaRes, catRes] = await Promise.all([
|
||||
Nova.api('docker', 'containers'),
|
||||
Nova.api('docker', 'quota-get'),
|
||||
Nova.api('docker', 'catalog'),
|
||||
]);
|
||||
|
||||
const containers = contRes?.data?.containers || [];
|
||||
const quota = quotaRes?.data?.quota || { max_containers: 2, max_memory_mb: 512, max_cpus: 1.0 };
|
||||
const catalog = catRes?.data?.catalog || {};
|
||||
const used = containers.length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Docker Containers</h2></div>
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
<div class="stat-card"><div class="stat-label">Containers Used</div><div class="stat-value">${used} / ${quota.max_containers}</div><div class="mt-1">${Nova.progressBar(Math.round(used/Math.max(quota.max_containers,1)*100))}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Max Memory / Container</div><div class="stat-value stat-blue">${quota.max_memory_mb} MB</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Max CPUs / Container</div><div class="stat-value stat-green">${quota.max_cpus}</div></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<button class="btn btn-sm ${_uDockerTab==='my-containers'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-containers')">My Containers</button>
|
||||
<button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button>
|
||||
</div>
|
||||
<div id="udocker-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
window._uDockerContainers = containers;
|
||||
window._uDockerQuota = quota;
|
||||
window._uDockerCatalog = catalog;
|
||||
window._uDockerTab = window._uDockerTab || 'my-containers';
|
||||
|
||||
window.uDockerTab = async (tab) => {
|
||||
window._uDockerTab = tab;
|
||||
document.querySelectorAll('[onclick^="uDockerTab"]').forEach(b => {
|
||||
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
|
||||
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
|
||||
});
|
||||
uDockerLoadTab(tab);
|
||||
};
|
||||
|
||||
uDockerLoadTab(window._uDockerTab);
|
||||
}
|
||||
|
||||
window._uDockerTab = 'my-containers';
|
||||
|
||||
function uDockerLoadTab(tab) {
|
||||
const tc = document.getElementById('udocker-content');
|
||||
if (!tc) return;
|
||||
const containers = window._uDockerContainers || [];
|
||||
const catalog = window._uDockerCatalog || {};
|
||||
const quota = window._uDockerQuota || {};
|
||||
|
||||
if (tab === 'my-containers') {
|
||||
tc.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<strong>${containers.length} container${containers.length===1?'':'s'}</strong>
|
||||
<button class="btn btn-sm btn-primary" onclick="uDockerLaunchModal()" ${containers.length>=quota.max_containers?'disabled title="Quota reached"':''}>+ Launch App</button>
|
||||
</div>
|
||||
${containers.length === 0
|
||||
? `<div class="card"><div class="card-body" style="text-align:center;padding:3rem">
|
||||
<div style="font-size:2.5rem;margin-bottom:1rem">🐳</div>
|
||||
<p class="text-muted">No containers yet. Launch an app from the catalog!</p>
|
||||
<button class="btn btn-primary" onclick="uDockerTab('catalog')">Browse Catalog</button>
|
||||
</div></div>`
|
||||
: `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>App</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${containers.map(c=>`<tr>
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||||
<td>${Nova.escHtml(c.app_key||c.image||'—')}</td>
|
||||
<td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td>
|
||||
<td style="white-space:nowrap">
|
||||
${c.status==='running'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="uDockerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="uDockerAct('${Nova.escHtml(c.container_id||'')}','restart')">Restart</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="uDockerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="uDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'catalog') {
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker container.</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="cursor:pointer;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)">${Nova.escHtml(app.description)}</div>
|
||||
<button class="btn btn-sm btn-primary" style="margin-top:1rem" onclick="uDockerLaunchApp('${key}')">Launch</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.uDockerAct = 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) {
|
||||
const c = (window._uDockerContainers||[]).find(x=>x.container_id===cid);
|
||||
if (c) c.status = action==='stop'?'stopped':'running';
|
||||
uDockerLoadTab('my-containers');
|
||||
}
|
||||
};
|
||||
|
||||
window.uDockerLogs = 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||'No logs available')}</pre>`);
|
||||
};
|
||||
|
||||
window.uDockerLaunchModal = () => uDockerLaunchApp(null);
|
||||
|
||||
window.uDockerLaunchApp = async (preselect) => {
|
||||
const catalog = window._uDockerCatalog || {};
|
||||
const entries = Object.entries(catalog);
|
||||
const appOpts = entries.map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
|
||||
|
||||
const ov = Nova.modal('Launch App',
|
||||
`<div class="form-group"><label>App</label>
|
||||
<select id="ul-app" class="form-control" onchange="uDockerUpdateParams(this.value)">${appOpts}</select></div>
|
||||
<div id="ul-params"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="uDockerLaunchSubmit()">Launch</button>`
|
||||
);
|
||||
|
||||
const initialKey = preselect || entries[0]?.[0];
|
||||
if (initialKey) uDockerUpdateParams(initialKey);
|
||||
|
||||
window.uDockerUpdateParams = (key) => {
|
||||
const app = catalog[key];
|
||||
if (!app) return;
|
||||
const tc = document.getElementById('ul-params');
|
||||
if (!tc) return;
|
||||
tc.innerHTML = (app.params||[]).map(p=>`
|
||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||
<input id="ul-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||
};
|
||||
|
||||
window.uDockerLaunchSubmit = async () => {
|
||||
const key = document.getElementById('ul-app')?.value;
|
||||
const app = catalog[key];
|
||||
if (!app) return;
|
||||
const params = {};
|
||||
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('ul-'+p.key)?.value||''; });
|
||||
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}… this may take a minute`, 'info', 15000);
|
||||
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } });
|
||||
Nova.toast(r?.success ? `${app.name} launched!` : (r?.message||'Launch failed'), r?.success?'success':'error');
|
||||
if (r?.success) {
|
||||
const cr = await Nova.api('docker', 'containers');
|
||||
window._uDockerContainers = cr?.data?.containers || [];
|
||||
uDockerTab('my-containers');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/* ── Boot ────────────────────────────────────────────────────────────────── */
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ok = await initUser();
|
||||
|
||||
Reference in New Issue
Block a user