mirror of
https://github.com/myronblair/novacpx
synced 2026-07-28 13:14:33 -05:00
Compare commits
15 Commits
8adedb9759
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6592e0e2 | |||
| a5b2a67708 | |||
| 233a2105ed | |||
| 321a88fa28 | |||
| ffb0683d4c | |||
| 80e964ee8f | |||
| bebf01658d | |||
| bc06cb1f22 | |||
| 1f179f1732 | |||
| f4726c2b2e | |||
| 89c1ffd184 | |||
| 4e371e8f2f | |||
| bc17356249 | |||
| 5d1f3aa241 | |||
| 32e374a2d0 |
@@ -79,6 +79,9 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT QUEUED_BRANCH; do
|
|||||||
rsync -av "$REPO_PATH/panel/bin/" /opt/novacpx/bin/ >> "$LOG" 2>&1
|
rsync -av "$REPO_PATH/panel/bin/" /opt/novacpx/bin/ >> "$LOG" 2>&1
|
||||||
chmod +x /opt/novacpx/bin/*.php 2>/dev/null || true
|
chmod +x /opt/novacpx/bin/*.php 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy VERSION to web root so /VERSION endpoint stays current
|
||||||
|
[[ -f "$REPO_PATH/VERSION" ]] && cp "$REPO_PATH/VERSION" "$WEB_ROOT/VERSION"
|
||||||
|
|
||||||
# Run pending DB migrations (SQLite)
|
# Run pending DB migrations (SQLite)
|
||||||
MIGR_DIR="$REPO_PATH/db/migrations"
|
MIGR_DIR="$REPO_PATH/db/migrations"
|
||||||
if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then
|
if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then
|
||||||
|
|||||||
@@ -29,6 +29,36 @@ match ($action) {
|
|||||||
Response::success(null, $msg);
|
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) {
|
'prune' => (function() use ($dm, $body, $isAdmin) {
|
||||||
if (!$isAdmin) Response::error('Admin only', 403);
|
if (!$isAdmin) Response::error('Admin only', 403);
|
||||||
$out = $dm->systemPrune((bool)($body['volumes'] ?? false));
|
$out = $dm->systemPrune((bool)($body['volumes'] ?? false));
|
||||||
@@ -163,20 +193,42 @@ match ($action) {
|
|||||||
Response::success($result, 'Stack created');
|
Response::success($result, 'Stack created');
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'stack-action' => (function() use ($dm, $body, $isAdmin) {
|
'stack-action' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||||
if (!$isAdmin) Response::error('Admin only', 403);
|
|
||||||
$id = (int)($body['stack_id'] ?? 0);
|
$id = (int)($body['stack_id'] ?? 0);
|
||||||
$act = $body['action'] ?? '';
|
$act = $body['action'] ?? '';
|
||||||
if (!$id || !$act) Response::error('stack_id and action required');
|
if (!$id || !$act) Response::error('stack_id and action required');
|
||||||
|
// Non-admins can only act on their own stacks
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||||
|
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||||
|
}
|
||||||
$out = $dm->composeAction($id, $act);
|
$out = $dm->composeAction($id, $act);
|
||||||
audit("docker.stack.{$act}", "stack:{$id}");
|
audit("docker.stack.{$act}", "stack:{$id}");
|
||||||
Response::success(['output' => $out]);
|
Response::success(['output' => $out]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'stack-remove' => (function() use ($dm, $body, $isAdmin) {
|
'stack-reinstall' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||||
if (!$isAdmin) Response::error('Admin only', 403);
|
|
||||||
$id = (int)($body['stack_id'] ?? 0);
|
$id = (int)($body['stack_id'] ?? 0);
|
||||||
if (!$id) Response::error('stack_id required');
|
if (!$id) Response::error('stack_id required');
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||||
|
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||||
|
}
|
||||||
|
// Pull latest images, bring down (removing volumes), then start fresh
|
||||||
|
$dm->composeAction($id, 'pull');
|
||||||
|
$dm->composeAction($id, 'down');
|
||||||
|
$out = $dm->composeAction($id, 'up');
|
||||||
|
audit('docker.stack.reinstall', "stack:{$id}");
|
||||||
|
Response::success(['output' => $out], 'Stack reinstalled');
|
||||||
|
})(),
|
||||||
|
|
||||||
|
'stack-remove' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||||
|
$id = (int)($body['stack_id'] ?? 0);
|
||||||
|
if (!$id) Response::error('stack_id required');
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||||
|
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||||
|
}
|
||||||
$dm->removeStack($id);
|
$dm->removeStack($id);
|
||||||
audit('docker.stack.remove', "stack:{$id}");
|
audit('docker.stack.remove', "stack:{$id}");
|
||||||
Response::success(null, 'Stack removed');
|
Response::success(null, 'Stack removed');
|
||||||
|
|||||||
@@ -20,5 +20,80 @@ match ($action) {
|
|||||||
Response::success($rows);
|
Response::success($rows);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
|
// Create a panel user (reseller or admin) — no hosting account provisioned
|
||||||
|
'create' => (function() use ($db, $body) {
|
||||||
|
$username = trim($body['username'] ?? '');
|
||||||
|
$password = $body['password'] ?? '';
|
||||||
|
$email = trim($body['email'] ?? '');
|
||||||
|
$role = $body['role'] ?? 'reseller';
|
||||||
|
|
||||||
|
if (!$username || !$password || !$email) Response::error('username, password and email are required');
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Invalid email address');
|
||||||
|
if (!in_array($role, ['reseller', 'admin'], true)) Response::error('role must be reseller or admin');
|
||||||
|
if (strlen($password) < 8) Response::error('Password must be at least 8 characters');
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $username)) Response::error('Username may only contain letters, numbers, _ - .');
|
||||||
|
|
||||||
|
$exists = $db->fetchOne("SELECT id FROM users WHERE username=? OR email=?", [$username, $email]);
|
||||||
|
if ($exists) Response::error('Username or email already in use');
|
||||||
|
|
||||||
|
$uid = (int)$db->insert(
|
||||||
|
"INSERT INTO users (username, password, email, role, status, created_at) VALUES (?,?,?,?,?,datetime('now'))",
|
||||||
|
[$username, password_hash($password, PASSWORD_BCRYPT), $email, $role, 'active']
|
||||||
|
);
|
||||||
|
audit("user.create.{$role}", "user:{$username}");
|
||||||
|
Response::success(['id' => $uid, 'username' => $username, 'email' => $email, 'role' => $role], ucfirst($role) . ' created');
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// Suspend a panel user (sets status=suspended on the users row)
|
||||||
|
'suspend' => (function() use ($db, $body) {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$id) Response::error('id required');
|
||||||
|
$u = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
|
||||||
|
if (!$u) Response::error('User not found', 404);
|
||||||
|
if ($u['role'] === 'admin') Response::error('Cannot suspend admin users');
|
||||||
|
$db->execute("UPDATE users SET status='suspended' WHERE id=?", [$id]);
|
||||||
|
audit('user.suspend', "user:{$u['username']}");
|
||||||
|
Response::success(null, 'User suspended');
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// Unsuspend a panel user
|
||||||
|
'unsuspend' => (function() use ($db, $body) {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$id) Response::error('id required');
|
||||||
|
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
|
||||||
|
if (!$u) Response::error('User not found', 404);
|
||||||
|
$db->execute("UPDATE users SET status='active' WHERE id=?", [$id]);
|
||||||
|
audit('user.unsuspend', "user:{$u['username']}");
|
||||||
|
Response::success(null, 'User unsuspended');
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// Change password for a panel user by user id
|
||||||
|
'change-password' => (function() use ($db, $body) {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
$pass = $body['password'] ?? '';
|
||||||
|
if (!$id) Response::error('id required');
|
||||||
|
if (strlen($pass) < 8) Response::error('Password must be at least 8 characters');
|
||||||
|
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
|
||||||
|
if (!$u) Response::error('User not found', 404);
|
||||||
|
$db->execute("UPDATE users SET password=? WHERE id=?", [password_hash($pass, PASSWORD_BCRYPT), $id]);
|
||||||
|
audit('user.change-password', "user:{$u['username']}");
|
||||||
|
Response::success(null, 'Password updated');
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// Delete a panel user (admin or reseller — not a hosting account user)
|
||||||
|
'delete' => (function() use ($db, $body) {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$id) Response::error('id required');
|
||||||
|
$user = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
|
||||||
|
if (!$user) Response::error('User not found', 404);
|
||||||
|
if ($user['role'] === 'admin') Response::error('Cannot delete admin users');
|
||||||
|
// Disown accounts under this reseller rather than deleting them
|
||||||
|
$db->execute("UPDATE users SET reseller_id=NULL WHERE reseller_id=?", [$id]);
|
||||||
|
$db->execute("UPDATE accounts SET reseller_id=NULL WHERE reseller_id=?", [$id]);
|
||||||
|
$db->execute("DELETE FROM users WHERE id=?", [$id]);
|
||||||
|
audit('user.delete', "user:{$user['username']}");
|
||||||
|
Response::success(null, 'User deleted');
|
||||||
|
})(),
|
||||||
|
|
||||||
default => Response::error("Unknown users action: $action", 404),
|
default => Response::error("Unknown users action: $action", 404),
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+124
-13
@@ -1166,7 +1166,7 @@
|
|||||||
|
|
||||||
// ── Resellers ──────────────────────────────────────────────────────────────
|
// ── Resellers ──────────────────────────────────────────────────────────────
|
||||||
async function resellers() {
|
async function resellers() {
|
||||||
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
|
const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }});
|
||||||
const rows = res?.data || [];
|
const rows = res?.data || [];
|
||||||
return `
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -1175,14 +1175,18 @@
|
|||||||
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
|
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="reseller-table">
|
<div id="reseller-table">
|
||||||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Accounts</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||||||
${rows.map(r => `<tr>
|
${rows.map(r => `<tr>
|
||||||
<td>${r.username}</td><td>${r.email||'—'}</td>
|
<td><strong>${Nova.escHtml(r.username)}</strong></td>
|
||||||
<td>${r.account_count||0}</td>
|
<td>${Nova.escHtml(r.email||'—')}</td>
|
||||||
<td>${Nova.badge(r.status,r.status==='active'?'green':'red')}</td>
|
<td>${Nova.badge(r.status, r.status==='active'?'green':'red')}</td>
|
||||||
|
<td class="text-sm text-muted">${r.created_at ? r.created_at.slice(0,10) : '—'}</td>
|
||||||
<td style="display:flex;gap:.25rem">
|
<td style="display:flex;gap:.25rem">
|
||||||
<button class="btn btn-xs" onclick="adminChangePass(${r.id},'${r.username}')">Passwd</button>
|
<button class="btn btn-xs" onclick="adminResellerPasswd(${r.id},'${Nova.escHtml(r.username)}')">Passwd</button>
|
||||||
<button class="btn btn-xs btn-danger" onclick="adminSuspend(${r.id},'${r.username}')">Suspend</button>
|
${r.status === 'active'
|
||||||
|
? `<button class="btn btn-xs btn-warning" onclick="adminResellerSuspend(${r.id},'${Nova.escHtml(r.username)}')">Suspend</button>`
|
||||||
|
: `<button class="btn btn-xs btn-success" onclick="adminResellerUnsuspend(${r.id})">Unsuspend</button>`}
|
||||||
|
<button class="btn btn-xs btn-danger" onclick="adminResellerDelete(${r.id},'${Nova.escHtml(r.username)}')">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
</tbody></table>`
|
</tbody></table>`
|
||||||
@@ -1193,10 +1197,51 @@
|
|||||||
|
|
||||||
window.adminAddReseller = () => {
|
window.adminAddReseller = () => {
|
||||||
Nova.modal('Create Reseller Account', `
|
Nova.modal('Create Reseller Account', `
|
||||||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control"></div>
|
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control" autocomplete="off"></div>
|
||||||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control"></div>
|
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>
|
||||||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>`,
|
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||||
`<button class="btn btn-primary" onclick="Nova.api('auth','register',{method:'POST',body:{username:document.getElementById('ar-user').value,password:document.getElementById('ar-pass').value,email:document.getElementById('ar-email').value,role:'reseller'}}).then(r=>{if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}else Nova.toast(r?.message,'error');})">Create</button>`);
|
`<button class="btn btn-primary" onclick="
|
||||||
|
Nova.api('users','create',{method:'POST',body:{
|
||||||
|
username:document.getElementById('ar-user').value,
|
||||||
|
email:document.getElementById('ar-email').value,
|
||||||
|
password:document.getElementById('ar-pass').value,
|
||||||
|
role:'reseller'
|
||||||
|
}}).then(r=>{
|
||||||
|
if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}
|
||||||
|
else Nova.toast(r?.message||'Error','error');
|
||||||
|
})">Create</button>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminResellerPasswd = (id, user) => {
|
||||||
|
Nova.modal(`Change Password — ${user}`,
|
||||||
|
`<div class="form-group"><label class="form-label">New Password</label><input id="arp-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||||
|
`<button class="btn btn-primary" onclick="
|
||||||
|
Nova.api('users','change-password',{method:'POST',body:{id:${id},password:document.getElementById('arp-pass').value}}).then(r=>{
|
||||||
|
if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}
|
||||||
|
else Nova.toast(r?.message||'Error','error');
|
||||||
|
})">Update</button>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminResellerSuspend = (id, user) => {
|
||||||
|
Nova.confirm(`Suspend reseller ${user}?`, async () => {
|
||||||
|
const r = await Nova.api('users','suspend',{method:'POST',body:{id}});
|
||||||
|
if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); }
|
||||||
|
else Nova.toast(r?.message||'Error','error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminResellerUnsuspend = async (id) => {
|
||||||
|
const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}});
|
||||||
|
if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); }
|
||||||
|
else Nova.toast(r?.message||'Error','error');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminResellerDelete = (id, user) => {
|
||||||
|
Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => {
|
||||||
|
const r = await Nova.api('users','delete',{method:'POST',body:{id}});
|
||||||
|
if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); }
|
||||||
|
else Nova.toast(r?.message||'Error','error');
|
||||||
|
}, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Packages ───────────────────────────────────────────────────────────────
|
// ── Packages ───────────────────────────────────────────────────────────────
|
||||||
@@ -4047,8 +4092,8 @@ async function docker() {
|
|||||||
${(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('')}
|
${(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>
|
||||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
<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')}
|
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')}
|
||||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
<button class="btn btn-sm btn-warning" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="docker-tab-content"><div class="loading">Loading…</div></div>`;
|
<div id="docker-tab-content"><div class="loading">Loading…</div></div>`;
|
||||||
}
|
}
|
||||||
@@ -4143,11 +4188,29 @@ ${stacks.map(s=>`<tr>
|
|||||||
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
|
<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-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-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" onclick="dockerStackReinstall(${s.id})">Reinstall</button>
|
||||||
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
</tbody></table></div>`}`;
|
</tbody></table></div>`}`;
|
||||||
|
|
||||||
|
} else if (tab === 'catalog') {
|
||||||
|
const r = await Nova.api('docker', 'catalog');
|
||||||
|
const catalog = r?.data?.catalog || {};
|
||||||
|
tc.innerHTML = `
|
||||||
|
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.</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="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);margin-bottom:.75rem">${Nova.escHtml(app.description)}</div>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="dockerAdminLaunchApp('${key}')">Launch</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
} else if (tab === 'quotas') {
|
} else if (tab === 'quotas') {
|
||||||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||||
const users = r?.data || [];
|
const users = r?.data || [];
|
||||||
@@ -4165,6 +4228,47 @@ ${users.map(u=>`<tr id="docker-quota-row-${u.user_id}">
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
|
||||||
|
const acctOpts = accounts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)} (${Nova.escHtml(a.domain)})</option>`).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=>`
|
||||||
|
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||||
|
<input id="dal-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ov = Nova.modal('Launch App (Admin)',
|
||||||
|
`<div class="form-group"><label>Account</label><select id="dal-account" class="form-control">${acctOpts}</select></div>
|
||||||
|
<div class="form-group"><label>Application</label><select id="dal-app" class="form-control" onchange="dockerAdminUpdateParams(this.value)">${appOpts}</select></div>
|
||||||
|
<div id="dal-params"></div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="dockerAdminLaunchSubmit()">Launch</button>`
|
||||||
|
);
|
||||||
|
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) => {
|
window.dockerContainerAct = async (cid, action) => {
|
||||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: 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');
|
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||||
@@ -4243,6 +4347,13 @@ window.dockerStackAct = async (id, action) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => {
|
||||||
|
Nova.toast('Reinstalling stack…', 'info', 15000);
|
||||||
|
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } });
|
||||||
|
Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error');
|
||||||
|
if (r?.success) dockerLoadTab('stacks');
|
||||||
|
}, true);
|
||||||
|
|
||||||
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
|
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 } });
|
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');
|
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||||
|
|||||||
@@ -1076,9 +1076,10 @@ async function dockerPage(el) {
|
|||||||
<div class="stat-card"><div class="stat-label">Max CPUs / App</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;flex-wrap:wrap">
|
||||||
<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==='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>
|
||||||
|
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="uDockerUninstallAll()">Remove All My Apps</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="udocker-content"><div class="loading">Loading…</div></div>`;
|
<div id="udocker-content"><div class="loading">Loading…</div></div>`;
|
||||||
|
|
||||||
@@ -1103,6 +1104,18 @@ async function dockerPage(el) {
|
|||||||
|
|
||||||
window._uDockerTab = 'my-apps';
|
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() {
|
async function uDockerReloadStacks() {
|
||||||
const r = await Nova.api('docker', 'stacks');
|
const r = await Nova.api('docker', 'stacks');
|
||||||
window._uDockerStacks = r?.data?.stacks || [];
|
window._uDockerStacks = r?.data?.stacks || [];
|
||||||
@@ -1142,6 +1155,7 @@ ${stacks.map(s=>`<tr>
|
|||||||
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
|
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
|
||||||
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`}
|
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">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="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" onclick="uStackReinstall(${s.id},'${Nova.escHtml(s.name)}')">Reinstall</button>
|
||||||
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
|
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
@@ -1180,10 +1194,22 @@ window.uStackLogs = async (stackId, name) => {
|
|||||||
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>`);
|
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.uStackReinstall = (stackId, name) => Nova.confirm(
|
||||||
|
`Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`,
|
||||||
|
async () => {
|
||||||
|
Nova.loading(`Reinstalling ${name}…`);
|
||||||
|
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } });
|
||||||
|
Nova.loadingDone();
|
||||||
|
Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error');
|
||||||
|
if (r?.success) await uDockerReloadStacks();
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
window.uStackRemove = async (stackId, name) => {
|
window.uStackRemove = async (stackId, name) => {
|
||||||
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
|
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
|
||||||
Nova.loading('Removing app…');
|
Nova.loading('Removing app…');
|
||||||
const r = await Nova.api('docker', 'remove-stack', { method: 'POST', body: { stack_id: stackId } });
|
const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } });
|
||||||
Nova.loadingDone();
|
Nova.loadingDone();
|
||||||
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||||
if (r?.success) await uDockerReloadStacks();
|
if (r?.success) await uDockerReloadStacks();
|
||||||
|
|||||||
Reference in New Issue
Block a user