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:
@@ -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