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:
2026-06-08 02:24:11 +00:00
parent aa93695459
commit 7c17e3696d
7 changed files with 1295 additions and 1 deletions
+4
View File
@@ -106,6 +106,10 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
WordPress
</a>
<a href="#" class="sidebar-link" data-page="docker">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="8" width="4" height="4" rx="1"/><rect x="8" y="8" width="4" height="4" rx="1"/><rect x="14" y="8" width="4" height="4" rx="1"/><rect x="8" y="2" width="4" height="4" rx="1"/><path d="M2 14c0 4 3 6 10 6s10-2 10-6"/><path d="M20 14c1.5 0 2.5-1 2.5-2.5S21.5 9 20 9h-1"/></svg>
Docker
</a>
</div>
<div class="sidebar-section">
+291
View File
@@ -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');
};
};
+156 -1
View File
@@ -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');
};
};
+163
View File
@@ -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();