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' : ''}>
|
<button class="btn btn-outline-primary action-terminal p-1" title="Terminal" ${container.State !== 'running' ? 'disabled' : ''}>
|
||||||
<i class="fas fa-terminal"></i>
|
<i class="fas fa-terminal"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button class="btn btn-outline-secondary action-more p-1" type="button" title="More actions" aria-label="More actions">
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<i class="fas fa-ellipsis"></i>
|
<i class="fas fa-ellipsis"></i>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -7553,19 +7531,6 @@ function buildContainerRow(container) {
|
|||||||
|
|
||||||
const checkbox = row.querySelector('.container-checkbox');
|
const checkbox = row.querySelector('.container-checkbox');
|
||||||
if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar());
|
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');
|
const nameLink = row.querySelector('.container-name-link');
|
||||||
if (nameLink) {
|
if (nameLink) {
|
||||||
nameLink.addEventListener('click', (e) => {
|
nameLink.addEventListener('click', (e) => {
|
||||||
@@ -7580,208 +7545,278 @@ function buildContainerRow(container) {
|
|||||||
showContainerDetails(container);
|
showContainerDetails(container);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const moreBtn = row.querySelector('.action-more');
|
||||||
|
if (moreBtn) {
|
||||||
|
moreBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openContainerActionsModal(container);
|
||||||
|
});
|
||||||
|
}
|
||||||
addActionListeners(row, container);
|
addActionListeners(row, container);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portaled container ⋮ menu — lives on document.body so it:
|
* Container "More actions" modal (replaces fragile ⋮ dropdown menus).
|
||||||
* - never expands table overflow / scrollbars
|
* @type {{ container: object|null }}
|
||||||
* - never sits under following rows
|
|
||||||
* - does not raise the whole <tr> (which stole mouse events over lower rows)
|
|
||||||
*/
|
*/
|
||||||
const containerActionsMenu = {
|
const containerActionsModalState = { container: null };
|
||||||
open: false,
|
|
||||||
toggle: null,
|
|
||||||
menu: null,
|
|
||||||
homeParent: null,
|
|
||||||
homeNext: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function positionPortaledContainerMenu(toggleBtn, menu) {
|
/** No-op: kept so list reconcile still has a safe close hook. */
|
||||||
const rect = toggleBtn.getBoundingClientRect();
|
function closeContainerActionMenus() {
|
||||||
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;
|
|
||||||
containerVirt.menuOpen = false;
|
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
|
* Open the designed more-actions modal for a container.
|
||||||
* overflow, multi-row hover glitches, and Popper/table fights).
|
* @param {object} container
|
||||||
* @param {HTMLElement} toggleBtn
|
|
||||||
*/
|
*/
|
||||||
function initContainerActionsDropdown(toggleBtn) {
|
function openContainerActionsModal(container) {
|
||||||
// no-op retained for call sites; real work is delegated click below
|
if (!container) return;
|
||||||
return toggleBtn || null;
|
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
|
function hideContainerActionsModal() {
|
||||||
document.addEventListener(
|
const modalEl = document.getElementById('containerActionsModal');
|
||||||
'click',
|
if (!modalEl || typeof bootstrap === 'undefined') return;
|
||||||
(e) => {
|
bootstrap.Modal.getInstance(modalEl)?.hide();
|
||||||
const btn = e.target?.closest?.('.container-actions-more-toggle');
|
}
|
||||||
if (btn) {
|
|
||||||
e.preventDefault();
|
function bindContainerActionsModalOnce() {
|
||||||
e.stopPropagation();
|
const modalEl = document.getElementById('containerActionsModal');
|
||||||
openPortaledContainerMenu(btn);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Click inside portaled menu — let item handlers run, then close
|
if (newName === name) {
|
||||||
const inMenu = e.target?.closest?.('.pd-portaled-actions-menu, .container-actions-dropdown-menu.show');
|
bootstrap.Modal.getInstance(renameModalEl)?.hide();
|
||||||
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);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Outside click closes
|
sendCommand('renameContainer', { id: container.Id, name: newName });
|
||||||
if (containerActionsMenu.open) {
|
bootstrap.Modal.getInstance(renameModalEl)?.hide();
|
||||||
restorePortaledContainerMenu();
|
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);
|
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
{ passive: true }
|
showAlert('info', 'Rename is unavailable in this build.');
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
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).
|
* In-place DOM reconcile — never clears tbody (no blank frame / disappear flash).
|
||||||
@@ -7791,8 +7826,8 @@ document.addEventListener('keydown', (e) => {
|
|||||||
function reconcileContainerRows(listElement, containers) {
|
function reconcileContainerRows(listElement, containers) {
|
||||||
if (!listElement) return;
|
if (!listElement) return;
|
||||||
|
|
||||||
// Menus with Popper fixed strategy desync when rows move — close first
|
// Close any open more-actions UI before DOM moves
|
||||||
closeContainerActionMenus(listElement);
|
closeContainerActionMenus();
|
||||||
|
|
||||||
// Drop skeleton / empty-state rows when we have real data
|
// Drop skeleton / empty-state rows when we have real data
|
||||||
if (containers.length) {
|
if (containers.length) {
|
||||||
@@ -8040,7 +8075,8 @@ function addActionListeners(row, container) {
|
|||||||
const killBtn = row.querySelector('.action-kill');
|
const killBtn = row.querySelector('.action-kill');
|
||||||
const topBtn = row.querySelector('.action-top');
|
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 () => {
|
startBtn.addEventListener('click', async () => {
|
||||||
showStatusIndicator(`Starting container "${container.Names[0]}"...`);
|
showStatusIndicator(`Starting container "${container.Names[0]}"...`);
|
||||||
sendCommand('startContainer', { id: container.Id });
|
sendCommand('startContainer', { id: container.Id });
|
||||||
@@ -8068,6 +8104,7 @@ function addActionListeners(row, container) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (stopBtn) {
|
||||||
stopBtn.addEventListener('click', async () => {
|
stopBtn.addEventListener('click', async () => {
|
||||||
showStatusIndicator(`Stopping container "${container.Names[0]}"...`);
|
showStatusIndicator(`Stopping container "${container.Names[0]}"...`);
|
||||||
sendCommand('stopContainer', { id: container.Id });
|
sendCommand('stopContainer', { id: container.Id });
|
||||||
@@ -8093,10 +8130,10 @@ function addActionListeners(row, container) {
|
|||||||
hideStatusIndicator();
|
hideStatusIndicator();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart / kill / pause / etc. live in the more-actions modal when not on the row
|
||||||
|
if (restartBtn) {
|
||||||
// Restart Button
|
|
||||||
restartBtn.addEventListener('click', async () => {
|
restartBtn.addEventListener('click', async () => {
|
||||||
showStatusIndicator(`Restarting container "${container.Names[0]}"...`);
|
showStatusIndicator(`Restarting container "${container.Names[0]}"...`);
|
||||||
sendCommand('restartContainer', { id: container.Id });
|
sendCommand('restartContainer', { id: container.Id });
|
||||||
@@ -8119,6 +8156,7 @@ function addActionListeners(row, container) {
|
|||||||
hideStatusIndicator();
|
hideStatusIndicator();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (killBtn) {
|
if (killBtn) {
|
||||||
killBtn.addEventListener('click', async () => {
|
killBtn.addEventListener('click', async () => {
|
||||||
@@ -8328,7 +8366,7 @@ function addActionListeners(row, container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logsBtn = row.querySelector('.action-logs');
|
const logsBtn = row.querySelector('.action-logs');
|
||||||
logsBtn.addEventListener('click', () => openLogModal(container.Id));
|
if (logsBtn) logsBtn.addEventListener('click', () => openLogModal(container.Id));
|
||||||
|
|
||||||
function openLogModal(containerId) {
|
function openLogModal(containerId) {
|
||||||
console.log(`[INFO] Opening logs modal for container: ${containerId}`);
|
console.log(`[INFO] Opening logs modal for container: ${containerId}`);
|
||||||
@@ -8359,8 +8397,8 @@ function addActionListeners(row, container) {
|
|||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove Button
|
// Remove Button (optional — often only on more-actions modal)
|
||||||
removeBtn.addEventListener('click', async () => {
|
if (removeBtn) removeBtn.addEventListener('click', async () => {
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
deleteModal.show();
|
deleteModal.show();
|
||||||
|
|
||||||
@@ -8427,7 +8465,7 @@ function addActionListeners(row, container) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (terminalBtn) {
|
||||||
terminalBtn.addEventListener('click', () => {
|
terminalBtn.addEventListener('click', () => {
|
||||||
console.log(`[DEBUG] Opening terminal for container ID: ${container.Id}`);
|
console.log(`[DEBUG] Opening terminal for container ID: ${container.Id}`);
|
||||||
try {
|
try {
|
||||||
@@ -8437,7 +8475,7 @@ function addActionListeners(row, container) {
|
|||||||
showAlert('danger', `Failed to start terminal: ${error.message}`);
|
showAlert('danger', `Failed to start terminal: ${error.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Inspect Button
|
// Inspect Button
|
||||||
if (inspectBtn) {
|
if (inspectBtn) {
|
||||||
inspectBtn.addEventListener('click', () => {
|
inspectBtn.addEventListener('click', () => {
|
||||||
|
|||||||
+101
@@ -3821,6 +3821,107 @@ services:
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Rename Container Modal -->
|
||||||
<div class="modal fade" id="renameContainerModal" tabindex="-1" aria-labelledby="renameContainerModalLabel" aria-hidden="true">
|
<div class="modal fade" id="renameContainerModal" tabindex="-1" aria-labelledby="renameContainerModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
+131
-47
@@ -2363,7 +2363,7 @@ textarea::placeholder {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Container action overflow */
|
/* Container action buttons */
|
||||||
.container-actions {
|
.container-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2371,7 +2371,6 @@ textarea::placeholder {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container-actions-primary {
|
.container-actions-primary {
|
||||||
@@ -2382,59 +2381,19 @@ textarea::placeholder {
|
|||||||
|
|
||||||
.container-actions-cell {
|
.container-actions-cell {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: 10.5rem;
|
width: 11rem;
|
||||||
max-width: 10.5rem;
|
max-width: 11rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container-actions-more {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-actions .btn {
|
.container-actions .btn {
|
||||||
flex: 0 0 auto;
|
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 {
|
#containers-view .table-responsive {
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -2448,11 +2407,136 @@ textarea::placeholder {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#containers-view #container-list > tr > td.container-actions-cell {
|
/* ── Container more-actions modal ── */
|
||||||
overflow: hidden;
|
.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 bodies must stay collapsed when not .show (defensive vs BS CDN lag) */
|
||||||
.accordion-collapse.collapse:not(.show) {
|
.accordion-collapse.collapse:not(.show) {
|
||||||
|
|||||||
Reference in New Issue
Block a user