Replace container ⋮ menu with a designed actions modal
Release rolling / release (push) Successful in 8m24s
Release rolling / release (push) Successful in 8m24s
Row more-button opens containerActionsModal with lifecycle, manage, and danger-zone actions instead of a fragile context/dropdown menu.
This commit is contained in:
@@ -7521,31 +7521,9 @@ function buildContainerRow(container) {
|
||||
<button class="btn btn-outline-primary action-terminal p-1" title="Terminal" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||
<i class="fas fa-terminal"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown container-actions-more">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary dropdown-toggle p-1 container-actions-more-toggle"
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
title="More actions"
|
||||
>
|
||||
<button class="btn btn-outline-secondary action-more p-1" type="button" title="More actions" aria-label="More actions">
|
||||
<i class="fas fa-ellipsis"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark container-actions-dropdown-menu" role="menu">
|
||||
<li><button type="button" class="dropdown-item action-restart" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-redo me-2"></i>Restart</button></li>
|
||||
<li><button type="button" class="dropdown-item action-kill" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-skull me-2"></i>Kill</button></li>
|
||||
<li><button type="button" class="dropdown-item action-pause" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-pause me-2"></i>Pause</button></li>
|
||||
<li><button type="button" class="dropdown-item action-top" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-microchip me-2"></i>Processes</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button type="button" class="dropdown-item action-inspect"><i class="fas fa-info-circle me-2"></i>Inspect</button></li>
|
||||
<li><button type="button" class="dropdown-item action-duplicate"><i class="fas fa-clone me-2"></i>Duplicate</button></li>
|
||||
<li><button type="button" class="dropdown-item action-rename"><i class="fas fa-edit me-2"></i>Rename</button></li>
|
||||
<li><button type="button" class="dropdown-item action-recreate" data-min-role="admin"><i class="fas fa-rotate me-2"></i>Recreate</button></li>
|
||||
<li><button type="button" class="dropdown-item action-resources" data-min-role="operator"><i class="fas fa-sliders me-2"></i>Resources…</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button type="button" class="dropdown-item text-danger action-remove"><i class="fas fa-trash me-2"></i>Remove</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -7553,19 +7531,6 @@ function buildContainerRow(container) {
|
||||
|
||||
const checkbox = row.querySelector('.container-checkbox');
|
||||
if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar());
|
||||
const duplicateBtn = row.querySelector('.action-duplicate');
|
||||
if (duplicateBtn) duplicateBtn.addEventListener('click', () => openDuplicateModal(container));
|
||||
const recreateBtn = row.querySelector('.action-recreate');
|
||||
if (recreateBtn) {
|
||||
recreateBtn.addEventListener('click', () => recreateContainerAction(container));
|
||||
}
|
||||
const resourcesBtn = row.querySelector('.action-resources');
|
||||
if (resourcesBtn) {
|
||||
resourcesBtn.addEventListener('click', () => {
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '');
|
||||
window.peardockOps?.openResourceEditor?.(container.Id, { name });
|
||||
});
|
||||
}
|
||||
const nameLink = row.querySelector('.container-name-link');
|
||||
if (nameLink) {
|
||||
nameLink.addEventListener('click', (e) => {
|
||||
@@ -7580,208 +7545,278 @@ function buildContainerRow(container) {
|
||||
showContainerDetails(container);
|
||||
});
|
||||
}
|
||||
const moreBtn = row.querySelector('.action-more');
|
||||
if (moreBtn) {
|
||||
moreBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openContainerActionsModal(container);
|
||||
});
|
||||
}
|
||||
addActionListeners(row, container);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portaled container ⋮ menu — lives on document.body so it:
|
||||
* - never expands table overflow / scrollbars
|
||||
* - never sits under following rows
|
||||
* - does not raise the whole <tr> (which stole mouse events over lower rows)
|
||||
* Container "More actions" modal (replaces fragile ⋮ dropdown menus).
|
||||
* @type {{ container: object|null }}
|
||||
*/
|
||||
const containerActionsMenu = {
|
||||
open: false,
|
||||
toggle: null,
|
||||
menu: null,
|
||||
homeParent: null,
|
||||
homeNext: null,
|
||||
};
|
||||
const containerActionsModalState = { container: null };
|
||||
|
||||
function positionPortaledContainerMenu(toggleBtn, menu) {
|
||||
const rect = toggleBtn.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
// Measure after show for flip-above if needed
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = '10050';
|
||||
menu.style.inset = 'auto';
|
||||
menu.style.transform = 'none';
|
||||
menu.style.margin = '0';
|
||||
menu.style.display = 'block';
|
||||
menu.style.visibility = 'hidden';
|
||||
// provisional place
|
||||
menu.style.top = `${Math.round(rect.bottom + pad)}px`;
|
||||
menu.style.left = '0px';
|
||||
menu.style.right = 'auto';
|
||||
|
||||
const mw = menu.offsetWidth || 184;
|
||||
const mh = menu.offsetHeight || 280;
|
||||
let top = rect.bottom + pad;
|
||||
if (top + mh > window.innerHeight - 8 && rect.top - pad - mh > 8) {
|
||||
top = rect.top - pad - mh;
|
||||
}
|
||||
let left = rect.right - mw;
|
||||
if (left < 8) left = 8;
|
||||
if (left + mw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - 8 - mw);
|
||||
|
||||
menu.style.top = `${Math.round(top)}px`;
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.visibility = 'visible';
|
||||
}
|
||||
|
||||
function restorePortaledContainerMenu() {
|
||||
const st = containerActionsMenu;
|
||||
if (!st.menu) return;
|
||||
const menu = st.menu;
|
||||
menu.classList.remove('show');
|
||||
menu.style.cssText = '';
|
||||
// Return to original parent if still in DOM
|
||||
if (st.homeParent && st.homeParent.isConnected) {
|
||||
if (st.homeNext && st.homeNext.parentNode === st.homeParent) {
|
||||
st.homeParent.insertBefore(menu, st.homeNext);
|
||||
} else {
|
||||
st.homeParent.appendChild(menu);
|
||||
}
|
||||
} else {
|
||||
menu.remove();
|
||||
}
|
||||
if (st.toggle) {
|
||||
st.toggle.setAttribute('aria-expanded', 'false');
|
||||
st.toggle.classList.remove('show');
|
||||
st.toggle.closest('.dropdown')?.classList.remove('show');
|
||||
}
|
||||
st.open = false;
|
||||
st.toggle = null;
|
||||
st.menu = null;
|
||||
st.homeParent = null;
|
||||
st.homeNext = null;
|
||||
/** No-op: kept so list reconcile still has a safe close hook. */
|
||||
function closeContainerActionMenus() {
|
||||
containerVirt.menuOpen = false;
|
||||
document.querySelectorAll('#container-list tr.row-menu-open').forEach((tr) => {
|
||||
tr.classList.remove('row-menu-open');
|
||||
});
|
||||
document.body.classList.remove('pd-container-menu-open');
|
||||
}
|
||||
|
||||
function openPortaledContainerMenu(toggleBtn) {
|
||||
if (!toggleBtn) return;
|
||||
// Same toggle → close
|
||||
if (containerActionsMenu.open && containerActionsMenu.toggle === toggleBtn) {
|
||||
restorePortaledContainerMenu();
|
||||
return;
|
||||
}
|
||||
// Different row → close previous first
|
||||
if (containerActionsMenu.open) restorePortaledContainerMenu();
|
||||
|
||||
let menu = toggleBtn.nextElementSibling;
|
||||
if (!menu?.classList?.contains('dropdown-menu')) {
|
||||
menu = toggleBtn.parentElement?.querySelector('.dropdown-menu');
|
||||
}
|
||||
if (!menu) return;
|
||||
|
||||
containerActionsMenu.homeParent = menu.parentElement;
|
||||
containerActionsMenu.homeNext = menu.nextSibling;
|
||||
containerActionsMenu.toggle = toggleBtn;
|
||||
containerActionsMenu.menu = menu;
|
||||
containerActionsMenu.open = true;
|
||||
containerVirt.menuOpen = true;
|
||||
|
||||
menu.classList.add('container-actions-dropdown-menu', 'show', 'pd-portaled-actions-menu');
|
||||
document.body.appendChild(menu);
|
||||
document.body.classList.add('pd-container-menu-open');
|
||||
toggleBtn.setAttribute('aria-expanded', 'true');
|
||||
toggleBtn.classList.add('show');
|
||||
toggleBtn.closest('.dropdown')?.classList.add('show');
|
||||
|
||||
positionPortaledContainerMenu(toggleBtn, menu);
|
||||
}
|
||||
|
||||
/** Close any open container action menus before DOM moves. */
|
||||
function closeContainerActionMenus(_root) {
|
||||
restorePortaledContainerMenu();
|
||||
// Belt-and-suspenders: hide any leftover shown menus in the table
|
||||
try {
|
||||
document.querySelectorAll?.('.container-actions-dropdown-menu.show, .pd-portaled-actions-menu').forEach((menu) => {
|
||||
menu.classList.remove('show', 'pd-portaled-actions-menu');
|
||||
menu.style.cssText = '';
|
||||
});
|
||||
document.querySelectorAll?.('.container-actions-more-toggle[aria-expanded="true"]').forEach((btn) => {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.classList.remove('show');
|
||||
btn.closest('.dropdown')?.classList.remove('show');
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
containerVirt.menuOpen = false;
|
||||
document.body.classList.remove('pd-container-menu-open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for ⋮ toggles — does not use Bootstrap Dropdown (avoids table
|
||||
* overflow, multi-row hover glitches, and Popper/table fights).
|
||||
* @param {HTMLElement} toggleBtn
|
||||
* Open the designed more-actions modal for a container.
|
||||
* @param {object} container
|
||||
*/
|
||||
function initContainerActionsDropdown(toggleBtn) {
|
||||
// no-op retained for call sites; real work is delegated click below
|
||||
return toggleBtn || null;
|
||||
function openContainerActionsModal(container) {
|
||||
if (!container) return;
|
||||
const modalEl = document.getElementById('containerActionsModal');
|
||||
if (!modalEl || typeof bootstrap === 'undefined') {
|
||||
console.warn('[WARN] containerActionsModal missing');
|
||||
return;
|
||||
}
|
||||
|
||||
containerActionsModalState.container = container;
|
||||
containerVirt.menuOpen = true;
|
||||
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id?.slice(0, 12) || 'Container';
|
||||
const state = container.State || 'unknown';
|
||||
const running = String(state).toLowerCase() === 'running';
|
||||
const image = formatImageName(container.Image || '—');
|
||||
const shortId = (container.Id || '').slice(0, 12);
|
||||
|
||||
const titleEl = document.getElementById('containerActionsModalLabel');
|
||||
if (titleEl) titleEl.textContent = name;
|
||||
|
||||
const subtitle = document.getElementById('cam-subtitle');
|
||||
if (subtitle) {
|
||||
subtitle.innerHTML = `
|
||||
<span class="cam-id mono">${escapeHtmlLite(shortId)}</span>
|
||||
<span class="cam-sep">·</span>
|
||||
<span class="badge ${containerStatusClass(state)}">${escapeHtmlLite(state)}</span>
|
||||
<span class="cam-sep">·</span>
|
||||
<span class="cam-image text-muted">${escapeHtmlLite(image)}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Enable/disable state-sensitive actions
|
||||
modalEl.querySelectorAll('[data-cam-need-running]').forEach((btn) => {
|
||||
btn.disabled = !running;
|
||||
});
|
||||
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Capture-phase: open portaled menu; block Bootstrap's data-api if present
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(e) => {
|
||||
const btn = e.target?.closest?.('.container-actions-more-toggle');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openPortaledContainerMenu(btn);
|
||||
function hideContainerActionsModal() {
|
||||
const modalEl = document.getElementById('containerActionsModal');
|
||||
if (!modalEl || typeof bootstrap === 'undefined') return;
|
||||
bootstrap.Modal.getInstance(modalEl)?.hide();
|
||||
}
|
||||
|
||||
function bindContainerActionsModalOnce() {
|
||||
const modalEl = document.getElementById('containerActionsModal');
|
||||
if (!modalEl || modalEl.dataset.bound === '1') return;
|
||||
modalEl.dataset.bound = '1';
|
||||
|
||||
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||
containerActionsModalState.container = null;
|
||||
containerVirt.menuOpen = false;
|
||||
if (containerVirt.rows.length > 80) {
|
||||
requestAnimationFrame(() => paintVirtualContainers());
|
||||
}
|
||||
});
|
||||
|
||||
modalEl.addEventListener('click', async (e) => {
|
||||
const btn = e.target?.closest?.('[data-cam-action]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const action = btn.getAttribute('data-cam-action');
|
||||
const container = containerActionsModalState.container;
|
||||
if (!container || !action) return;
|
||||
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id;
|
||||
const hide = () => hideContainerActionsModal();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'restart': {
|
||||
hide();
|
||||
showStatusIndicator(`Restarting container "${name}"...`);
|
||||
sendCommand('restartContainer', { id: container.Id });
|
||||
try {
|
||||
const response = await waitForPeerResponse(`Container ${container.Id} restarted`);
|
||||
showAlert('success', response.message);
|
||||
sendCommand('listContainers');
|
||||
} catch (err) {
|
||||
showAlert('danger', err.message || 'Failed to restart container.');
|
||||
} finally {
|
||||
hideStatusIndicator();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'kill': {
|
||||
hide();
|
||||
showStatusIndicator(`Killing container "${name}"...`);
|
||||
try {
|
||||
const response = await sendCommand('killContainer', { id: container.Id });
|
||||
if (response?.success) {
|
||||
showAlert('success', response.message || 'Container killed');
|
||||
sendCommand('listContainers');
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('danger', err.message || 'Failed to kill container.');
|
||||
} finally {
|
||||
hideStatusIndicator();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pause': {
|
||||
hide();
|
||||
showStatusIndicator(`Pausing container "${name}"...`);
|
||||
sendCommand('pauseContainer', { id: container.Id });
|
||||
try {
|
||||
const response = await waitForPeerResponse(`Container ${container.Id} paused`);
|
||||
showAlert('success', response.message);
|
||||
sendCommand('listContainers');
|
||||
} catch (err) {
|
||||
showAlert('danger', err.message || 'Failed to pause container.');
|
||||
} finally {
|
||||
hideStatusIndicator();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'processes': {
|
||||
hide();
|
||||
showContainerDetails(container);
|
||||
setTimeout(() => {
|
||||
document.getElementById('processes-tab')?.click();
|
||||
if (typeof loadContainerTop === 'function') loadContainerTop(container.Id);
|
||||
}, 200);
|
||||
break;
|
||||
}
|
||||
case 'inspect': {
|
||||
hide();
|
||||
showContainerDetails(container);
|
||||
break;
|
||||
}
|
||||
case 'duplicate': {
|
||||
hide();
|
||||
openDuplicateModal(container);
|
||||
break;
|
||||
}
|
||||
case 'rename': {
|
||||
hide();
|
||||
// Prefer dedicated rename modal when present
|
||||
const renameModalEl = document.getElementById('renameContainerModal');
|
||||
const input = document.getElementById('new-container-name');
|
||||
if (renameModalEl && input && typeof bootstrap !== 'undefined') {
|
||||
input.value = name;
|
||||
input.dataset.containerId = container.Id;
|
||||
bootstrap.Modal.getOrCreateInstance(renameModalEl).show();
|
||||
const confirmBtn = document.getElementById('confirm-rename-btn');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.onclick = async () => {
|
||||
const newName = input.value.trim();
|
||||
if (!newName) {
|
||||
showAlert('danger', 'Container name cannot be empty');
|
||||
return;
|
||||
}
|
||||
// Click inside portaled menu — let item handlers run, then close
|
||||
const inMenu = e.target?.closest?.('.pd-portaled-actions-menu, .container-actions-dropdown-menu.show');
|
||||
if (inMenu && containerActionsMenu.open) {
|
||||
// Allow the action button click to process; close on next tick
|
||||
const item = e.target?.closest?.('.dropdown-item, button, a');
|
||||
if (item && !item.disabled) {
|
||||
setTimeout(() => restorePortaledContainerMenu(), 0);
|
||||
}
|
||||
if (newName === name) {
|
||||
bootstrap.Modal.getInstance(renameModalEl)?.hide();
|
||||
return;
|
||||
}
|
||||
// Outside click closes
|
||||
if (containerActionsMenu.open) {
|
||||
restorePortaledContainerMenu();
|
||||
sendCommand('renameContainer', { id: container.Id, name: newName });
|
||||
bootstrap.Modal.getInstance(renameModalEl)?.hide();
|
||||
showStatusIndicator(`Renaming to "${newName}"...`);
|
||||
try {
|
||||
const response = await waitForPeerResponse(`Container renamed to "${newName}"`);
|
||||
showAlert('success', response.message || `Renamed to ${newName}`);
|
||||
sendCommand('listContainers');
|
||||
} catch (err) {
|
||||
showAlert('danger', err.message || 'Rename failed');
|
||||
} finally {
|
||||
hideStatusIndicator();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Reposition on resize; close on scroll of the table so menu doesn't float wrong
|
||||
window.addEventListener(
|
||||
'resize',
|
||||
() => {
|
||||
if (containerActionsMenu.open && containerActionsMenu.toggle && containerActionsMenu.menu) {
|
||||
positionPortaledContainerMenu(containerActionsMenu.toggle, containerActionsMenu.menu);
|
||||
};
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
'scroll',
|
||||
(e) => {
|
||||
if (!containerActionsMenu.open) return;
|
||||
// Ignore scrolls inside the portaled menu itself
|
||||
if (e.target?.closest?.('.pd-portaled-actions-menu')) return;
|
||||
restorePortaledContainerMenu();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && containerActionsMenu.open) {
|
||||
restorePortaledContainerMenu();
|
||||
} else {
|
||||
showAlert('info', 'Rename is unavailable in this build.');
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'recreate': {
|
||||
hide();
|
||||
if (typeof recreateContainerAction === 'function') recreateContainerAction(container);
|
||||
break;
|
||||
}
|
||||
case 'resources': {
|
||||
hide();
|
||||
window.peardockOps?.openResourceEditor?.(container.Id, { name });
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
hide();
|
||||
const deleteModalEl = document.getElementById('deleteModal');
|
||||
if (deleteModalEl && typeof bootstrap !== 'undefined') {
|
||||
const deleteModal = bootstrap.Modal.getOrCreateInstance(deleteModalEl);
|
||||
deleteModal.show();
|
||||
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
||||
if (confirmDeleteBtn) {
|
||||
confirmDeleteBtn.onclick = async () => {
|
||||
deleteModal.hide();
|
||||
if (typeof closeAllModals === 'function') closeAllModals();
|
||||
notificationManager?.add?.('info', `Deleting container "${name}"...`, {
|
||||
autoDismiss: false,
|
||||
});
|
||||
showStatusIndicator(`Deleting container "${name}"...`);
|
||||
if (window.openTerminals?.[container.Id]) {
|
||||
window.openTerminals[container.Id].forEach((terminalId) => {
|
||||
try {
|
||||
cleanUpTerminal(terminalId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
delete window.openTerminals[container.Id];
|
||||
}
|
||||
sendCommand('removeContainer', { id: container.Id });
|
||||
try {
|
||||
const response = await waitForPeerResponse(
|
||||
`Container ${container.Id} removed`,
|
||||
30000
|
||||
);
|
||||
showAlert('success', response.message || 'Container removed');
|
||||
sendCommand('listContainers');
|
||||
} catch (err) {
|
||||
showAlert('danger', err.message || 'Failed to remove container.');
|
||||
} finally {
|
||||
hideStatusIndicator();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('danger', err.message || 'Action failed');
|
||||
hideStatusIndicator();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bind modal once DOM is ready (module scripts often load after DOMContentLoaded)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bindContainerActionsModalOnce);
|
||||
} else {
|
||||
bindContainerActionsModalOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place DOM reconcile — never clears tbody (no blank frame / disappear flash).
|
||||
@@ -7791,8 +7826,8 @@ document.addEventListener('keydown', (e) => {
|
||||
function reconcileContainerRows(listElement, containers) {
|
||||
if (!listElement) return;
|
||||
|
||||
// Menus with Popper fixed strategy desync when rows move — close first
|
||||
closeContainerActionMenus(listElement);
|
||||
// Close any open more-actions UI before DOM moves
|
||||
closeContainerActionMenus();
|
||||
|
||||
// Drop skeleton / empty-state rows when we have real data
|
||||
if (containers.length) {
|
||||
@@ -8040,7 +8075,8 @@ function addActionListeners(row, container) {
|
||||
const killBtn = row.querySelector('.action-kill');
|
||||
const topBtn = row.querySelector('.action-top');
|
||||
|
||||
// Start Button
|
||||
// Start Button (primary row actions only — more actions use the modal)
|
||||
if (!startBtn) return;
|
||||
startBtn.addEventListener('click', async () => {
|
||||
showStatusIndicator(`Starting container "${container.Names[0]}"...`);
|
||||
sendCommand('startContainer', { id: container.Id });
|
||||
@@ -8068,6 +8104,7 @@ function addActionListeners(row, container) {
|
||||
});
|
||||
|
||||
|
||||
if (stopBtn) {
|
||||
stopBtn.addEventListener('click', async () => {
|
||||
showStatusIndicator(`Stopping container "${container.Names[0]}"...`);
|
||||
sendCommand('stopContainer', { id: container.Id });
|
||||
@@ -8093,10 +8130,10 @@ function addActionListeners(row, container) {
|
||||
hideStatusIndicator();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Restart Button
|
||||
// Restart / kill / pause / etc. live in the more-actions modal when not on the row
|
||||
if (restartBtn) {
|
||||
restartBtn.addEventListener('click', async () => {
|
||||
showStatusIndicator(`Restarting container "${container.Names[0]}"...`);
|
||||
sendCommand('restartContainer', { id: container.Id });
|
||||
@@ -8119,6 +8156,7 @@ function addActionListeners(row, container) {
|
||||
hideStatusIndicator();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (killBtn) {
|
||||
killBtn.addEventListener('click', async () => {
|
||||
@@ -8328,7 +8366,7 @@ function addActionListeners(row, container) {
|
||||
}
|
||||
|
||||
const logsBtn = row.querySelector('.action-logs');
|
||||
logsBtn.addEventListener('click', () => openLogModal(container.Id));
|
||||
if (logsBtn) logsBtn.addEventListener('click', () => openLogModal(container.Id));
|
||||
|
||||
function openLogModal(containerId) {
|
||||
console.log(`[INFO] Opening logs modal for container: ${containerId}`);
|
||||
@@ -8359,8 +8397,8 @@ function addActionListeners(row, container) {
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Remove Button
|
||||
removeBtn.addEventListener('click', async () => {
|
||||
// Remove Button (optional — often only on more-actions modal)
|
||||
if (removeBtn) removeBtn.addEventListener('click', async () => {
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
deleteModal.show();
|
||||
|
||||
@@ -8427,7 +8465,7 @@ function addActionListeners(row, container) {
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
if (terminalBtn) {
|
||||
terminalBtn.addEventListener('click', () => {
|
||||
console.log(`[DEBUG] Opening terminal for container ID: ${container.Id}`);
|
||||
try {
|
||||
@@ -8437,7 +8475,7 @@ function addActionListeners(row, container) {
|
||||
showAlert('danger', `Failed to start terminal: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
// Inspect Button
|
||||
if (inspectBtn) {
|
||||
inspectBtn.addEventListener('click', () => {
|
||||
|
||||
+101
@@ -3821,6 +3821,107 @@ services:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container more-actions modal (replaces ⋮ context menu) -->
|
||||
<div class="modal fade" id="containerActionsModal" tabindex="-1" aria-labelledby="containerActionsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content cam-modal bg-dark text-white">
|
||||
<div class="modal-header cam-header border-0 pb-0">
|
||||
<div class="cam-header-text">
|
||||
<h5 class="modal-title mb-1" id="containerActionsModalLabel">Container</h5>
|
||||
<div id="cam-subtitle" class="cam-subtitle small"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body cam-body pt-3">
|
||||
<div class="cam-section-label">Lifecycle</div>
|
||||
<div class="cam-grid">
|
||||
<button type="button" class="cam-action" data-cam-action="restart" data-cam-need-running>
|
||||
<span class="cam-action-icon"><i class="fas fa-redo"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Restart</strong>
|
||||
<small>Stop then start the container</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action" data-cam-action="pause" data-cam-need-running>
|
||||
<span class="cam-action-icon"><i class="fas fa-pause"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Pause</strong>
|
||||
<small>Freeze all processes</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action cam-action-warn" data-cam-action="kill" data-cam-need-running>
|
||||
<span class="cam-action-icon"><i class="fas fa-skull"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Kill</strong>
|
||||
<small>Force stop (SIGKILL)</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action" data-cam-action="processes" data-cam-need-running>
|
||||
<span class="cam-action-icon"><i class="fas fa-microchip"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Processes</strong>
|
||||
<small>View running process list</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cam-section-label mt-3">Manage</div>
|
||||
<div class="cam-grid">
|
||||
<button type="button" class="cam-action" data-cam-action="inspect">
|
||||
<span class="cam-action-icon"><i class="fas fa-info-circle"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Inspect</strong>
|
||||
<small>Details, logs & config</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action" data-cam-action="duplicate">
|
||||
<span class="cam-action-icon"><i class="fas fa-clone"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Duplicate</strong>
|
||||
<small>Create from this config</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action" data-cam-action="rename">
|
||||
<span class="cam-action-icon"><i class="fas fa-edit"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Rename</strong>
|
||||
<small>Change container name</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action" data-cam-action="recreate" data-min-role="admin">
|
||||
<span class="cam-action-icon"><i class="fas fa-rotate"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Recreate</strong>
|
||||
<small>Replace with same settings</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action" data-cam-action="resources" data-min-role="operator">
|
||||
<span class="cam-action-icon"><i class="fas fa-sliders"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Resources</strong>
|
||||
<small>CPU & memory limits</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cam-section-label mt-3">Danger zone</div>
|
||||
<div class="cam-grid">
|
||||
<button type="button" class="cam-action cam-action-danger" data-cam-action="remove">
|
||||
<span class="cam-action-icon"><i class="fas fa-trash"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Remove</strong>
|
||||
<small>Delete this container</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rename Container Modal -->
|
||||
<div class="modal fade" id="renameContainerModal" tabindex="-1" aria-labelledby="renameContainerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
|
||||
+131
-47
@@ -2363,7 +2363,7 @@ textarea::placeholder {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Container action overflow */
|
||||
/* Container action buttons */
|
||||
.container-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2371,7 +2371,6 @@ textarea::placeholder {
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container-actions-primary {
|
||||
@@ -2382,59 +2381,19 @@ textarea::placeholder {
|
||||
|
||||
.container-actions-cell {
|
||||
white-space: nowrap;
|
||||
width: 10.5rem;
|
||||
max-width: 10.5rem;
|
||||
width: 11rem;
|
||||
max-width: 11rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.container-actions-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container-actions .btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* In-table menus stay hidden — open menu is portaled to body */
|
||||
.container-actions .dropdown-menu:not(.pd-portaled-actions-menu),
|
||||
.container-actions-dropdown-menu:not(.pd-portaled-actions-menu) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Portaled ⋮ menu (appended to <body> while open).
|
||||
* position:fixed + high z-index — never expands table overflow/scrollbars.
|
||||
*/
|
||||
.pd-portaled-actions-menu,
|
||||
.dropdown-menu.pd-portaled-actions-menu,
|
||||
.container-actions-dropdown-menu.pd-portaled-actions-menu {
|
||||
position: fixed !important;
|
||||
z-index: 10050 !important;
|
||||
min-width: 11.5rem;
|
||||
max-height: min(70vh, 28rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
font-size: 13px;
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
pointer-events: auto !important;
|
||||
margin: 0 !important;
|
||||
inset: auto !important;
|
||||
transform: none !important;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Containers table shell — never unlock overflow when menu open */
|
||||
#containers-view .table-responsive {
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2448,11 +2407,136 @@ textarea::placeholder {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#containers-view #container-list > tr > td.container-actions-cell {
|
||||
overflow: hidden;
|
||||
/* ── Container more-actions modal ── */
|
||||
.cam-modal {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-elevated) !important;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Do NOT elevate open rows — that stole mouse events over rows below */
|
||||
.cam-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cam-header-text {
|
||||
min-width: 0;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.cam-subtitle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cam-subtitle .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cam-sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.cam-image {
|
||||
max-width: 14rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cam-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cam-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.cam-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cam-action {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.03));
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.12s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cam-action:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cam-action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cam-action-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: var(--accent-info, #38bdf8);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cam-action-warn .cam-action-icon {
|
||||
background: rgba(251, 191, 36, 0.14);
|
||||
color: var(--accent-warning, #fbbf24);
|
||||
}
|
||||
|
||||
.cam-action-danger .cam-action-icon {
|
||||
background: rgba(248, 113, 113, 0.14);
|
||||
color: var(--accent-danger, #f87171);
|
||||
}
|
||||
|
||||
.cam-action-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cam-action-text strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cam-action-text small {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Accordion bodies must stay collapsed when not .show (defensive vs BS CDN lag) */
|
||||
.accordion-collapse.collapse:not(.show) {
|
||||
|
||||
Reference in New Issue
Block a user