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:
@@ -25,6 +25,8 @@ const bundles = {
|
||||
JS('core', 'service-crud.js'),
|
||||
JS('core', 'service-create.js'),
|
||||
JS('live-events.js'),
|
||||
JS('service-filter.js'),
|
||||
JS('batch-operations.js'),
|
||||
],
|
||||
'features.js': [
|
||||
JS('logo-customization.js'),
|
||||
@@ -34,6 +36,7 @@ const bundles = {
|
||||
JS('import-export.js'),
|
||||
JS('error-logs.js'),
|
||||
JS('container-logs.js'),
|
||||
JS('snapshot.js'),
|
||||
JS('smart-arr-connect.js'),
|
||||
JS('notification-settings.js'),
|
||||
JS('panel-tabs.js'),
|
||||
|
||||
Vendored
+62
-62
File diff suppressed because one or more lines are too long
Vendored
+178
-124
File diff suppressed because one or more lines are too long
@@ -164,6 +164,7 @@
|
||||
<div class="tools-section-items">
|
||||
<button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button>
|
||||
<button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button>
|
||||
<button id="snapshot-btn" aria-label="Container snapshots">💾 Snapshots</button>
|
||||
<button id="license-btn" aria-label="License management">🔑 License</button>
|
||||
<button id="api-docs-btn" aria-label="API documentation">📖 API</button>
|
||||
<button id="help-errors-btn" aria-label="Troubleshooting guide">❓ Help</button>
|
||||
@@ -247,6 +248,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Filter Bar -->
|
||||
<div id="service-filter-bar" style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap;">
|
||||
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
|
||||
<select id="service-filter-status" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Status</option>
|
||||
<option value="on">🟢 Online</option>
|
||||
<option value="off">🔴 Offline</option>
|
||||
</select>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Batch Operations Bar (hidden by default) -->
|
||||
<div id="batch-action-bar" style="display: none; margin-bottom: 16px; padding: 12px 16px; background: var(--accent-bg, #1a3a5c); border: 1px solid var(--accent, #3498db); border-radius: var(--radius);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<span id="batch-selected-count" style="font-weight: 500;">0 selected</span>
|
||||
<button id="batch-start-btn" class="btn-sm" style="background: var(--ok-fg, #27ae60);">▶ Start All</button>
|
||||
<button id="batch-stop-btn" class="btn-sm" style="background: var(--bad-fg, #e74c3c);">⬛ Stop All</button>
|
||||
<button id="batch-restart-btn" class="btn-sm" style="background: #f39c12;">🔄 Restart All</button>
|
||||
<button id="batch-cancel-btn" class="btn-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App/service grid -->
|
||||
<div id="cards" class="grid"></div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user