Docker page: show compose stacks in My Apps tab instead of raw containers

launchFromCatalog creates compose stacks, not docker_containers entries.
Replace My Containers tab with My Apps tab backed by docker/stacks endpoint.
Add Refresh, Start/Stop, Logs, Remove actions per stack row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:18:38 +00:00
parent cdb92c5ef2
commit 877f157665
+69 -52
View File
@@ -1058,35 +1058,34 @@ window.submitChangePassword = async () => {
/* ── Docker (#34) ────────────────────────────────────────────────────────── */ /* ── Docker (#34) ────────────────────────────────────────────────────────── */
async function dockerPage(el) { async function dockerPage(el) {
el.innerHTML = '<div class="loading">Loading Docker…</div>'; el.innerHTML = '<div class="loading">Loading Docker…</div>';
const [contRes, quotaRes, catRes] = await Promise.all([ const [stackRes, quotaRes, catRes] = await Promise.all([
Nova.api('docker', 'containers'), Nova.api('docker', 'stacks'),
Nova.api('docker', 'quota-get'), Nova.api('docker', 'quota-get'),
Nova.api('docker', 'catalog'), Nova.api('docker', 'catalog'),
]); ]);
const containers = contRes?.data?.containers || []; const stacks = stackRes?.data?.stacks || [];
const quota = quotaRes?.data?.quota || { max_containers: 2, max_memory_mb: 512, max_cpus: 1.0 }; const quota = quotaRes?.data?.quota || { max_containers: 2, max_memory_mb: 512, max_cpus: 1.0 };
const catalog = catRes?.data?.catalog || {}; const catalog = catRes?.data?.catalog || {};
const used = containers.length;
el.innerHTML = ` el.innerHTML = `
<div class="page-header"><h2 class="page-title">Docker Containers</h2></div> <div class="page-header"><h2 class="page-title">Docker Apps</h2></div>
<div class="stats-grid" style="margin-bottom:1.5rem"> <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">Apps Deployed</div><div class="stat-value">${stacks.length} / ${quota.max_containers}</div><div class="mt-1">${Nova.progressBar(Math.round(stacks.length/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 Memory / App</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 class="stat-card"><div class="stat-label">Max CPUs / App</div><div class="stat-value stat-green">${quota.max_cpus}</div></div>
</div> </div>
<div style="display:flex;gap:.5rem;margin-bottom:1rem"> <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==='my-apps'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-apps')">My Apps</button>
<button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button> <button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button>
</div> </div>
<div id="udocker-content"><div class="loading">Loading…</div></div>`; <div id="udocker-content"><div class="loading">Loading…</div></div>`;
window._uDockerContainers = containers; window._uDockerStacks = stacks;
window._uDockerQuota = quota; window._uDockerQuota = quota;
window._uDockerCatalog = catalog; window._uDockerCatalog = catalog;
window._uDockerTab = window._uDockerTab || 'my-containers'; window._uDockerTab = window._uDockerTab || 'my-apps';
window.uDockerTab = async (tab) => { window.uDockerTab = async (tab) => {
window._uDockerTab = tab; window._uDockerTab = tab;
@@ -1094,44 +1093,56 @@ async function dockerPage(el) {
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1]; const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost'); b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
}); });
uDockerLoadTab(tab); if (tab === 'my-apps') await uDockerReloadStacks();
else uDockerLoadTab(tab);
}; };
uDockerLoadTab(window._uDockerTab); if (window._uDockerTab === 'my-apps') await uDockerReloadStacks();
else uDockerLoadTab(window._uDockerTab);
} }
window._uDockerTab = 'my-containers'; window._uDockerTab = 'my-apps';
async function uDockerReloadStacks() {
const r = await Nova.api('docker', 'stacks');
window._uDockerStacks = r?.data?.stacks || [];
uDockerLoadTab('my-apps');
}
function uDockerLoadTab(tab) { function uDockerLoadTab(tab) {
const tc = document.getElementById('udocker-content'); const tc = document.getElementById('udocker-content');
if (!tc) return; if (!tc) return;
const containers = window._uDockerContainers || []; const stacks = window._uDockerStacks || [];
const catalog = window._uDockerCatalog || {}; const catalog = window._uDockerCatalog || {};
const quota = window._uDockerQuota || {}; const quota = window._uDockerQuota || {};
if (tab === 'my-containers') { if (tab === 'my-apps') {
const statusColor = s => s==='running'?'green':s==='starting'?'yellow':s==='stopped'?'red':'yellow';
tc.innerHTML = ` tc.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<strong>${containers.length} container${containers.length===1?'':'s'}</strong> <strong>${stacks.length} app${stacks.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 style="display:flex;gap:.5rem">
<button class="btn btn-sm btn-ghost" onclick="uDockerReloadStacks()">↻ Refresh</button>
<button class="btn btn-sm btn-primary" onclick="uDockerLaunchModal()" ${stacks.length>=quota.max_containers?'disabled title="Quota reached"':''}>+ Launch App</button>
</div>
</div> </div>
${containers.length === 0 ${stacks.length === 0
? `<div class="card"><div class="card-body" style="text-align:center;padding:3rem"> ? `<div class="card"><div class="card-body" style="text-align:center;padding:3rem">
<div style="font-size:2.5rem;margin-bottom:1rem">🐳</div> <div style="font-size:2.5rem;margin-bottom:1rem">🐳</div>
<p class="text-muted">No containers yet. Launch an app from the catalog!</p> <p class="text-muted">No apps yet. Launch one from the catalog!</p>
<button class="btn btn-primary" onclick="uDockerTab('catalog')">Browse Catalog</button> <button class="btn btn-primary" onclick="uDockerTab('catalog')">Browse Catalog</button>
</div></div>` </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> : `<div style="overflow-x:auto"><table class="table"><thead><tr><th>App</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
${containers.map(c=>`<tr> ${stacks.map(s=>`<tr>
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td> <td style="font-weight:600">${Nova.escHtml(s.name)}</td>
<td>${Nova.escHtml(c.app_key||c.image||'—')}</td> <td>${Nova.badge(s.status, statusColor(s.status))}</td>
<td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td> <td class="text-muted text-sm">${Nova.relTime(s.created_at)}</td>
<td style="white-space:nowrap"> <td style="white-space:nowrap">
${c.status==='running' ${s.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-warning" onclick="uStackAct(${s.id},'down')">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="uStackAct(${s.id},'up')">Start</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="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
<button class="btn btn-xs btn-ghost" onclick="uDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button> <button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
</td> </td>
</tr>`).join('')} </tr>`).join('')}
</tbody></table></div>`}`; </tbody></table></div>`}`;
@@ -1153,21 +1164,29 @@ ${Object.entries(catalog).map(([key,app])=>`
} }
} }
window.uDockerAct = async (cid, action) => { window.uStackAct = async (stackId, action) => {
Nova.loading(`${action.charAt(0).toUpperCase()+action.slice(1)}ing container…`); const label = action === 'up' ? 'Starting' : 'Stopping';
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } }); Nova.loading(`${label} app…`);
const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: stackId, action } });
Nova.loadingDone(); Nova.loadingDone();
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error'); Nova.toast(r?.success ? `App ${action==='up'?'started':'stopped'}` : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) { if (r?.success) await uDockerReloadStacks();
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) => { window.uStackLogs = async (stackId, name) => {
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } }); Nova.loading('Fetching logs');
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>`); const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: stackId, action: 'logs' } });
Nova.loadingDone();
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`);
};
window.uStackRemove = async (stackId, name) => {
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
Nova.loading('Removing app…');
const r = await Nova.api('docker', 'remove-stack', { method: 'POST', body: { stack_id: stackId } });
Nova.loadingDone();
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) await uDockerReloadStacks();
}; };
window.uDockerLaunchModal = () => uDockerLaunchApp(null); window.uDockerLaunchModal = () => uDockerLaunchApp(null);
@@ -1210,11 +1229,9 @@ window.uDockerLaunchApp = async (preselect) => {
Nova.loading(`Launching ${app.name}… this may take a minute`); Nova.loading(`Launching ${app.name}… this may take a minute`);
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } }); const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } });
Nova.loadingDone(); Nova.loadingDone();
Nova.toast(r?.success ? `${app.name} launched!` : (r?.message||'Launch failed'), r?.success?'success':'error'); Nova.toast(r?.success ? `${app.name} launching — refresh in a moment to see status` : (r?.message||'Launch failed'), r?.success?'success':'error');
if (r?.success) { if (r?.success) {
const cr = await Nova.api('docker', 'containers'); await uDockerTab('my-apps');
window._uDockerContainers = cr?.data?.containers || [];
uDockerTab('my-containers');
} }
}; };
}; };