diff --git a/panel/api/endpoints/docker.php b/panel/api/endpoints/docker.php
index 75122e2..0fc6ee6 100644
--- a/panel/api/endpoints/docker.php
+++ b/panel/api/endpoints/docker.php
@@ -29,6 +29,36 @@ match ($action) {
Response::success(null, $msg);
})(),
+ 'uninstall-account' => (function() use ($dm, $currentUser, $isAdmin, $_userAccountId, $body) {
+ // Stop and remove all containers and stacks belonging to one account.
+ // Users can only remove their own; admins can specify any account_id.
+ $accountId = $isAdmin ? (int)($body['account_id'] ?? $_userAccountId) : ($_userAccountId ?? 0);
+ if (!$accountId) Response::error('account_id required');
+
+ $db = DB::getInstance();
+
+ // Tear down compose stacks
+ $stacks = $db->fetchAll("SELECT * FROM docker_compose_stacks WHERE account_id = ?", [$accountId]);
+ foreach ($stacks as $stack) {
+ if (is_dir($stack['stack_dir']) && file_exists("{$stack['stack_dir']}/docker-compose.yml")) {
+ shell_exec("sudo docker compose -f " . escapeshellarg("{$stack['stack_dir']}/docker-compose.yml") . " down -v 2>/dev/null");
+ }
+ $db->execute("DELETE FROM docker_compose_stacks WHERE id = ?", [$stack['id']]);
+ }
+
+ // Stop and remove bare containers
+ $containers = $db->fetchAll("SELECT container_id FROM docker_containers WHERE account_id = ?", [$accountId]);
+ foreach ($containers as $c) {
+ if ($c['container_id']) {
+ shell_exec("sudo docker rm -f " . escapeshellarg($c['container_id']) . " 2>/dev/null");
+ }
+ }
+ $db->execute("DELETE FROM docker_containers WHERE account_id = ?", [$accountId]);
+
+ audit("docker.uninstall-account", "account:{$accountId}");
+ Response::success(null, 'All Docker apps and containers removed for this account');
+ })(),
+
'prune' => (function() use ($dm, $body, $isAdmin) {
if (!$isAdmin) Response::error('Admin only', 403);
$out = $dm->systemPrune((bool)($body['volumes'] ?? false));
diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js
index a8e9ee6..b74715b 100644
--- a/panel/public/assets/js/admin.js
+++ b/panel/public/assets/js/admin.js
@@ -4047,8 +4047,8 @@ async function docker() {
${(status.disk||[]).map(d=>`
${Nova.escHtml(d.Type||d.type||'?')}
${Nova.escHtml(d.TotalCount||d.Size||'—')}
${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}
`).join('')}
- ${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')}
-
+ ${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')}
+
`;
}
@@ -4148,6 +4148,23 @@ ${stacks.map(s=>`
`).join('')}
`}`;
+ } else if (tab === 'catalog') {
+ const r = await Nova.api('docker', 'catalog');
+ const catalog = r?.data?.catalog || {};
+ tc.innerHTML = `
+One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.
+
+${Object.entries(catalog).map(([key,app])=>`
+
+
+
${Nova.escHtml(app.icon)}
+
${Nova.escHtml(app.name)}
+
${Nova.escHtml(app.description)}
+
+
+
`).join('')}
+
`;
+
} else if (tab === 'quotas') {
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
const users = r?.data || [];
@@ -4165,6 +4182,47 @@ ${users.map(u=>`
}
}
+window.dockerAdminLaunchApp = async (preselect) => {
+ const catRes = await Nova.api('docker', 'catalog');
+ const catalog = catRes?.data?.catalog || {};
+ const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } });
+ const accounts = acctRes?.data || [];
+ const appOpts = Object.entries(catalog).map(([k,a])=>``).join('');
+ const acctOpts = accounts.map(a=>``).join('');
+
+ window.dockerAdminUpdateParams = (key) => {
+ const app = catalog[key]; if (!app) return;
+ const tc = document.getElementById('dal-params'); if (!tc) return;
+ tc.innerHTML = (app.params||[]).map(p=>`
+
+
`).join('');
+ };
+
+ const ov = Nova.modal('Launch App (Admin)',
+ `
+
+ `,
+ `
+ `
+ );
+ dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || '');
+
+ window.dockerAdminLaunchSubmit = async () => {
+ const appKey = document.getElementById('dal-app').value;
+ const accountId = parseInt(document.getElementById('dal-account').value);
+ const app = catalog[appKey]; if (!app) return;
+ const params = {};
+ (app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); });
+ 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}…`, 'info', 15000);
+ const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } });
+ Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error');
+ if (r?.success) dockerLoadTab('stacks');
+ };
+};
+
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');
diff --git a/panel/public/assets/js/user.js b/panel/public/assets/js/user.js
index 01f0f76..bbe8b5a 100644
--- a/panel/public/assets/js/user.js
+++ b/panel/public/assets/js/user.js
@@ -1076,9 +1076,10 @@ async function dockerPage(el) {
Max CPUs / App
${quota.max_cpus}
-
+
+
`;
@@ -1103,6 +1104,18 @@ async function dockerPage(el) {
window._uDockerTab = 'my-apps';
+window.uDockerUninstallAll = () => Nova.confirm(
+ 'Remove ALL your Docker apps? This will stop and delete every container and stack you own. Your hosting account and websites are not affected.',
+ async () => {
+ Nova.loading('Removing all your Docker apps…');
+ const r = await Nova.api('docker', 'uninstall-account', { method: 'POST', body: {} });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? 'All Docker apps removed' : (r?.error || r?.message || 'Failed'), r?.success ? 'success' : 'error');
+ if (r?.success) await uDockerReloadStacks();
+ },
+ true
+);
+
async function uDockerReloadStacks() {
const r = await Nova.api('docker', 'stacks');
window._uDockerStacks = r?.data?.stacks || [];