Add service filter, batch operations, and snapshot features

- Service Filter Bar: search by name, filter by status (online/offline)
- Batch Operations: multi-select containers for start/stop/restart
- Container Snapshots: create and manage Docker checkpoints
- Added filter bar and batch action bar to index.html
- Added snapshot button to Admin tools section
- New JS modules: service-filter.js, batch-operations.js, snapshot.js
- Updated build.js to include new modules in bundle
This commit is contained in:
Krystie
2026-05-15 02:17:24 -07:00
parent 0cec447bf1
commit 77688daec7
7 changed files with 642 additions and 186 deletions
+138
View File
@@ -0,0 +1,138 @@
// ========== BATCH CONTAINER OPERATIONS ==========
(function() {
const batchBtn = document.getElementById('batch-operations-btn');
const batchBar = document.getElementById('batch-action-bar');
const batchCount = document.getElementById('batch-selected-count');
const startBtn = document.getElementById('batch-start-btn');
const stopBtn = document.getElementById('batch-stop-btn');
const restartBtn = document.getElementById('batch-restart-btn');
const cancelBtn = document.getElementById('batch-cancel-btn');
let batchMode = false;
let selectedContainers = new Set();
function enterBatchMode() {
batchMode = true;
selectedContainers.clear();
batchBar.style.display = '';
batchBtn.textContent = '✓ Exit Batch Mode';
updateSelectedCount();
// Add checkboxes to all cards with containerId
const cards = document.querySelectorAll('#cards .card[data-app]');
cards.forEach(card => {
const containerId = card.dataset.containerId;
if (!containerId) return;
// Remove existing checkbox if any
const existing = card.querySelector('.batch-checkbox');
if (existing) existing.remove();
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'batch-checkbox';
checkbox.dataset.containerId = containerId;
checkbox.dataset.serviceName = card.querySelector('.name')?.textContent || containerId;
checkbox.style.cssText = 'position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;';
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
if (checkbox.checked) {
selectedContainers.add(containerId);
} else {
selectedContainers.delete(containerId);
}
updateSelectedCount();
});
card.style.position = 'relative';
card.insertBefore(checkbox, card.firstChild);
});
}
function exitBatchMode() {
batchMode = false;
selectedContainers.clear();
batchBar.style.display = 'none';
batchBtn.textContent = '☰ Batch Operations';
// Remove all checkboxes
document.querySelectorAll('.batch-checkbox').forEach(cb => cb.remove());
}
function updateSelectedCount() {
const count = selectedContainers.size;
batchCount.textContent = `${count} selected`;
startBtn.disabled = count === 0;
stopBtn.disabled = count === 0;
restartBtn.disabled = count === 0;
}
async function batchAction(action) {
if (selectedContainers.size === 0) return;
const containers = Array.from(selectedContainers);
const actionLabel = { start: 'Starting', stop: 'Stopping', restart: 'Restarting' }[action];
if (!confirm(`${actionLabel} ${containers.length} container(s)? This cannot be undone.`)) return;
const btns = [startBtn, stopBtn, restartBtn];
btns.forEach(b => { b.disabled = true; b.textContent = '...'; });
let success = 0;
let failed = 0;
const errors = [];
for (const containerId of containers) {
try {
const res = await fetch(`/api/v1/containers/${encodeURIComponent(containerId)}/${action}`, {
method: 'POST'
});
if (res.ok) {
success++;
} else {
failed++;
const data = await res.json().catch(() => ({}));
errors.push(`${containerId}: ${data.error || res.statusText}`);
}
} catch (e) {
failed++;
errors.push(`${containerId}: ${e.message}`);
}
}
// Restore buttons
btns[0].textContent = '▶ Start All';
btns[1].textContent = '⬛ Stop All';
btns[2].textContent = '🔄 Restart All';
updateSelectedCount();
// Show results
if (failed === 0) {
if (typeof showNotification === 'function') {
showNotification(`${actionLabel} completed: ${success} container(s)`, 'success');
}
} else {
if (typeof showNotification === 'function') {
showNotification(`${actionLabel}: ${success} succeeded, ${failed} failed`, 'warning');
}
console.error('Batch operation errors:', errors);
}
// Refresh dashboard after a short delay
setTimeout(() => {
if (typeof refreshAll === 'function') refreshAll();
}, 1500);
}
batchBtn?.addEventListener('click', () => {
if (batchMode) {
exitBatchMode();
} else {
enterBatchMode();
}
});
startBtn?.addEventListener('click', () => batchAction('start'));
stopBtn?.addEventListener('click', () => batchAction('stop'));
restartBtn?.addEventListener('click', () => batchAction('restart'));
cancelBtn?.addEventListener('click', exitBatchMode);
})();
+57
View File
@@ -0,0 +1,57 @@
// ========== SERVICE FILTER ==========
(function() {
const searchInput = document.getElementById('service-filter-search');
const statusSelect = document.getElementById('service-filter-status');
const countSpan = document.getElementById('service-filter-count');
function updateFilter() {
const query = searchInput.value.toLowerCase().trim();
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
const cards = document.querySelectorAll('#cards .card');
let visibleCount = 0;
cards.forEach(card => {
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
const app = card.dataset.app?.toLowerCase() || '';
const status = card.dataset.status || 'off'; // 'on' or 'off'
const matchesSearch = !query || name.includes(query) || app.includes(query);
const matchesStatus = statusFilter === 'all' || status === statusFilter;
if (matchesSearch && matchesStatus) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
if (countSpan) {
const total = cards.length;
countSpan.textContent = `${visibleCount} of ${total} services`;
}
}
// Debounce helper
function debounce(fn, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
searchInput?.addEventListener('input', debounce(updateFilter, 200));
statusSelect?.addEventListener('change', updateFilter);
// Initial count on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(updateFilter, 500));
} else {
setTimeout(updateFilter, 500);
}
// Expose for external triggers
window.refreshServiceFilter = updateFilter;
})();
+180
View File
@@ -0,0 +1,180 @@
// ========== CONTAINER SNAPSHOT / CHECKPOINT ==========
(function() {
// Inject modal HTML
injectModal('snapshot-modal', `<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>💾 Container Snapshots</h3>
<p class="modal-subtitle">
Create and manage Docker container checkpoints for instant state recovery.
</p>
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
<option value="">-- Select a container --</option>
</select>
</div>
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
</div>
</div>
<div class="panel-tabs" style="margin-bottom: 12px;">
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
</div>
<div id="snapshot-create" class="panel-section active">
<div style="margin-bottom: 12px;">
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
</div>
<div style="margin-bottom: 12px;">
<label class="checkbox-label" style="font-size: 0.85rem;">
<input type="checkbox" id="snapshot-leave-running" checked />
Leave container running after checkpoint (resume without restart)
</label>
</div>
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">💾 Create Snapshot</button>
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
</div>
<div id="snapshot-list" class="panel-section" style="display: none;">
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
<div class="panel-empty"><span class="empty-icon">💾</span>Select a container to view its snapshots</div>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('snapshot-modal');
const openBtn = document.getElementById('snapshot-btn');
const closeBtn = document.getElementById('snapshot-close');
const containerSelect = document.getElementById('snapshot-container-select');
const detailsDiv = document.getElementById('snapshot-details');
const createBtn = document.getElementById('snapshot-create-btn');
const createStatus = document.getElementById('snapshot-create-status');
let currentContainerId = null;
async function loadContainers() {
try {
const res = await fetch('/api/v1/containers');
const data = await res.json();
if (!data.success || !data.containers) return;
containerSelect.innerHTML = '<option value="">-- Select a container --</option>';
for (const c of data.containers) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${c.name || c.id} (${c.image || 'unknown'})`;
opt.dataset.name = c.name;
opt.dataset.image = c.image;
opt.dataset.status = c.status;
opt.dataset.created = c.created;
containerSelect.appendChild(opt);
}
} catch (e) {
console.error('Failed to load containers:', e);
}
}
function showContainerDetails(opt) {
if (!opt || !opt.value) {
detailsDiv.style.display = 'none';
currentContainerId = null;
return;
}
currentContainerId = opt.value;
document.getElementById('snapshot-image').textContent = opt.dataset.image || '-';
document.getElementById('snapshot-status').textContent = opt.dataset.status || '-';
document.getElementById('snapshot-created').textContent = opt.dataset.created ? new Date(opt.dataset.created * 1000).toLocaleString() : '-';
document.getElementById('snapshot-id').textContent = opt.value.substring(0, 12);
detailsDiv.style.display = '';
}
async function createSnapshot() {
if (!currentContainerId) {
createStatus.textContent = 'Please select a container first';
createStatus.style.color = 'var(--bad-fg)';
return;
}
const name = document.getElementById('snapshot-name').value.trim();
if (!name) {
createStatus.textContent = 'Please enter a snapshot name';
createStatus.style.color = 'var(--bad-fg)';
return;
}
const leaveRunning = document.getElementById('snapshot-leave-running').checked;
createBtn.disabled = true;
createBtn.textContent = 'Creating...';
createStatus.textContent = '';
try {
const res = await fetch(`/api/v1/containers/${encodeURIComponent(currentContainerId)}/checkpoint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, leaveRunning })
});
const data = await res.json();
if (data.success) {
createStatus.textContent = `✓ Snapshot "${name}" created successfully`;
createStatus.style.color = 'var(--ok-fg)';
document.getElementById('snapshot-name').value = '';
} else {
createStatus.textContent = `✗ Failed: ${data.error || 'Unknown error'}`;
createStatus.style.color = 'var(--bad-fg)';
}
} catch (e) {
createStatus.textContent = `✗ Error: ${e.message}`;
createStatus.style.color = 'var(--bad-fg)';
} finally {
createBtn.disabled = false;
createBtn.textContent = '💾 Create Snapshot';
}
}
function openModal() {
modal.classList.add('show');
loadContainers();
}
function closeModal() {
modal.classList.remove('show');
detailsDiv.style.display = 'none';
currentContainerId = null;
containerSelect.selectedIndex = 0;
}
openBtn?.addEventListener('click', openModal);
closeBtn?.addEventListener('click', closeModal);
wireModal(modal, closeBtn);
containerSelect?.addEventListener('change', (e) => {
const opt = containerSelect.options[containerSelect.selectedIndex];
showContainerDetails(opt);
});
createBtn?.addEventListener('click', createSnapshot);
// Tab switching
modal?.querySelectorAll('.panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
modal.querySelectorAll('.panel-tab').forEach(t => t.classList.remove('active'));
modal.querySelectorAll('.panel-section').forEach(s => s.classList.remove('active'));
tab.classList.add('active');
modal.querySelector(`#${tab.dataset.panel}`).classList.add('active');
});
});
})();