From f34ce0ead3a541479ce0464ee18959a2f2e5c7db Mon Sep 17 00:00:00 2001 From: Alexander Sabino <32822107+asabino2@users.noreply.github.com> Date: Mon, 4 May 2026 18:46:43 +0100 Subject: [PATCH] =?UTF-8?q?Adiciona=20sele=C3=A7=C3=A3o=20de=20volumes=20p?= =?UTF-8?q?ara=20backup=20e=20implementa=20modal=20de=20escolha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/app.js | 183 +++++++++++++++++++++++++++++++++++++++++-- public/index.html | 15 ++++ public/styles.css | 11 +++ src/backupService.js | 30 ++++++- src/server.js | 19 +++++ src/store.js | 1 + 6 files changed, 250 insertions(+), 9 deletions(-) diff --git a/public/app.js b/public/app.js index 7abc256..db0aab6 100644 --- a/public/app.js +++ b/public/app.js @@ -2,6 +2,7 @@ const state = { containers: [], profiles: [], activeRuns: new Map(), + volumeSelections: {}, }; const elements = { @@ -21,6 +22,12 @@ const elements = { restoreModalConfirm: document.querySelector('#restoreModalConfirm'), restoreModalClose: document.querySelector('#restoreModalClose'), restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'), + volumePickerModal: document.querySelector('#volumePickerModal'), + volumePickerSubtitle: document.querySelector('#volumePickerSubtitle'), + volumePickerOptions: document.querySelector('#volumePickerOptions'), + volumePickerConfirm: document.querySelector('#volumePickerConfirm'), + volumePickerClose: document.querySelector('#volumePickerClose'), + volumePickerSelectAll: document.querySelector('#volumePickerSelectAll'), }; async function api(path, options = {}) { @@ -70,25 +77,38 @@ function getSelectedContainerIds() { return [...document.querySelectorAll('input[name="containerIds"]:checked')].map((input) => input.value); } +const SYSTEM_MOUNT_PREFIXES = ['/dev', '/proc', '/sys', '/run', '/tmp']; + +function isMountBlocked(destination) { + return SYSTEM_MOUNT_PREFIXES.some( + (prefix) => destination === prefix || destination.startsWith(prefix + '/'), + ); +} + function renderContainers() { elements.containerCount.textContent = String(state.containers.length); - if (!state.containers.length) { + const eligible = state.containers.filter((container) => container.state !== 'created'); + + if (!eligible.length) { elements.containerOptions.innerHTML = '

Nenhum container encontrado.

'; return; } const selected = new Set(getSelectedContainerIds()); - elements.containerOptions.innerHTML = state.containers.map((container) => ` + elements.containerOptions.innerHTML = eligible.map((container) => { + const hasVolumeSelection = Boolean(state.volumeSelections[container.id]?.length); + return ` - `).join(''); + `; + }).join(''); } function backupButtons(profile) { @@ -121,6 +141,141 @@ function getProfileScopeLabel(scope) { return scope === 'container' ? 'container inteiro' : 'somente volumes'; } +async function askVolumeSelection(containerId, containerName, currentSelections) { + let mounts; + try { + mounts = await api(`/api/containers/${encodeURIComponent(containerId)}/mounts`); + } catch (error) { + showToast(`Falha ao buscar volumes de ${containerName}: ${error.message}`, true); + return null; + } + + if (!mounts.length) { + showToast(`Container ${containerName} não possui volumes.`, true); + return null; + } + + const hasEligible = mounts.some((m) => !isMountBlocked(m.destination)); + if (!hasEligible) { + showToast(`Container ${containerName} não possui volumes elegíveis (todos são caminhos de sistema).`, true); + return null; + } + + elements.volumePickerSubtitle.textContent = `Container: ${containerName}`; + elements.volumePickerOptions.innerHTML = mounts.map((mount) => { + const blocked = isMountBlocked(mount.destination); + const isChecked = currentSelections + ? currentSelections.includes(mount.destination) + : !blocked; + return ` + + `; + }).join(''); + + elements.volumePickerModal.classList.remove('hidden'); + elements.volumePickerModal.setAttribute('aria-hidden', 'false'); + + return new Promise((resolve) => { + const closeModal = () => { + elements.volumePickerModal.classList.add('hidden'); + elements.volumePickerModal.setAttribute('aria-hidden', 'true'); + elements.volumePickerOptions.innerHTML = ''; + }; + + const cleanup = () => { + elements.volumePickerConfirm.removeEventListener('click', onConfirm); + elements.volumePickerClose.removeEventListener('click', onCancel); + elements.volumePickerSelectAll.removeEventListener('click', onSelectAll); + elements.volumePickerModal.removeEventListener('click', onBackdropClick); + }; + + const onConfirm = () => { + const selected = [...elements.volumePickerOptions.querySelectorAll('input[name="volumePaths"]:checked')] + .map((input) => input.value); + if (!selected.length) { + showToast('Selecione ao menos um volume.', true); + return; + } + cleanup(); + closeModal(); + resolve(selected); + }; + + const onCancel = () => { + cleanup(); + closeModal(); + resolve(null); + }; + + const onSelectAll = () => { + for (const input of elements.volumePickerOptions.querySelectorAll('input[name="volumePaths"]:not([disabled])')) { + input.checked = true; + } + }; + + const onBackdropClick = (event) => { + if (event.target.closest('[data-action="close-volume-picker"]')) { + onCancel(); + } + }; + + elements.volumePickerConfirm.addEventListener('click', onConfirm); + elements.volumePickerClose.addEventListener('click', onCancel); + elements.volumePickerSelectAll.addEventListener('click', onSelectAll); + elements.volumePickerModal.addEventListener('click', onBackdropClick); + }); +} + +async function handleContainerCheck(event) { + const input = event.target.closest('input[name="containerIds"]'); + if (!input) { + return; + } + + const scope = document.querySelector('input[name="backupScope"]:checked')?.value; + if (scope !== 'volumes') { + return; + } + + const containerId = input.value; + const label = input.closest('label'); + const containerName = label?.querySelector('strong')?.textContent || containerId.slice(0, 12); + + if (!input.checked) { + delete state.volumeSelections[containerId]; + renderContainers(); + input.checked = false; + return; + } + + const currentSelections = state.volumeSelections[containerId] || null; + const selected = await askVolumeSelection(containerId, containerName, currentSelections); + + if (selected === null) { + input.checked = false; + return; + } + + state.volumeSelections[containerId] = selected; + renderContainers(); + const updatedInput = document.querySelector(`input[name="containerIds"][value="${CSS.escape(containerId)}"]`); + if (updatedInput) { + updatedInput.checked = true; + } +} + function askRestoreContainerSelection(profile, backup) { const restorable = (backup.containers || []).filter((item) => item.status === 'ok'); if (!restorable.length) { @@ -382,6 +537,7 @@ function resetForm() { elements.profileForm.reset(); elements.profileId.value = ''; elements.formModeBadge.textContent = 'criar'; + state.volumeSelections = {}; renderContainers(); } @@ -392,6 +548,7 @@ function fillForm(profile) { const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes'; document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true; elements.formModeBadge.textContent = 'editar'; + state.volumeSelections = Object.assign({}, profile.volumeSelections || {}); renderContainers(); for (const containerId of profile.containerIds) { const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`); @@ -414,12 +571,25 @@ async function loadProfiles() { async function saveProfile(event) { event.preventDefault(); + const selectedContainerIds = getSelectedContainerIds(); + const backupScope = document.querySelector('input[name="backupScope"]:checked').value; + + const volumeSelections = {}; + if (backupScope === 'volumes') { + for (const id of selectedContainerIds) { + if (state.volumeSelections[id]?.length) { + volumeSelections[id] = state.volumeSelections[id]; + } + } + } + const payload = { id: elements.profileId.value || undefined, name: elements.profileName.value, backupDir: elements.backupDir.value, - containerIds: getSelectedContainerIds(), - backupScope: document.querySelector('input[name="backupScope"]:checked').value, + containerIds: selectedContainerIds, + backupScope, + volumeSelections, }; try { @@ -588,6 +758,7 @@ async function init() { } elements.profileForm.addEventListener('submit', saveProfile); +elements.containerOptions.addEventListener('change', handleContainerCheck); document.querySelector('#refreshContainers').addEventListener('click', init); document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles); document.querySelector('#clearForm').addEventListener('click', resetForm); diff --git a/public/index.html b/public/index.html index 9956fcd..7ecb5d5 100644 --- a/public/index.html +++ b/public/index.html @@ -108,6 +108,21 @@ + \ No newline at end of file diff --git a/public/styles.css b/public/styles.css index e3dbb16..aa792d0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -503,6 +503,17 @@ input[type='text'] { color: var(--muted); } +.volume-blocked { + opacity: 0.55; + background: rgba(160, 56, 56, 0.05); + border-color: rgba(160, 56, 56, 0.18); + cursor: not-allowed; +} + +.volume-blocked input { + cursor: not-allowed; +} + .modal-actions { display: flex; justify-content: space-between; diff --git a/src/backupService.js b/src/backupService.js index 3053b7b..e8e3fdc 100644 --- a/src/backupService.js +++ b/src/backupService.js @@ -306,11 +306,35 @@ class BackupService { const archiveRelativePath = path.posix.join(safeProfileName, safeContainerName, `${stamp}-${runMode}.tar.gz`); const snapshotRelativePath = path.posix.join(safeProfileName, safeContainerName, 'latest.snar'); + // Filter mounts to only those the user selected (if volumeSelections is defined for this container) + const selectedPaths = profile.volumeSelections?.[containerId] || profile.volumeSelections?.[inspect.Id]; + const activeMounts = (backupScope === 'volumes' && selectedPaths?.length) + ? mounts.filter((m) => selectedPaths.includes(m.destination)) + : mounts; + + if (backupScope === 'volumes' && !activeMounts.length) { + onProgress({ + containerName, + status: 'skipped', + step: 'concluido', + message: 'Nenhum volume selecionado para backup.', + percent: 100, + file: { current: 0, total: 0, currentFile: null, percent: 100 }, + }); + return { + containerId: inspect.Id, + containerName, + status: 'skipped', + mode: runMode, + message: 'Nenhum volume selecionado para backup.', + }; + } + const containerBackup = { containerId: inspect.Id, containerName, backupScope, - backupPaths: backupScope === 'container' ? ['/'] : mounts.map((mount) => mount.destination), + backupPaths: backupScope === 'container' ? ['/'] : activeMounts.map((mount) => mount.destination), mountSignature: mounts, archiveRelativePath, snapshotRelativePath, @@ -378,7 +402,7 @@ class BackupService { const sourcePaths = backupScope === 'container' ? ['/'] - : mounts.map((mount) => mount.destination); + : activeMounts.map((mount) => mount.destination); const relSourcePaths = sourcePaths.map((item) => toContainerRelPath(item)); if (backupScope === 'volumes') { @@ -480,7 +504,7 @@ class BackupService { } const binds = [`${backupRoot}:/backuproot`]; - for (const [index, mount] of mounts.entries()) { + for (const [index, mount] of activeMounts.entries()) { binds.push(`${getMountBindingSource(mount)}:/payload/m${index}:ro`); } diff --git a/src/server.js b/src/server.js index 0ed0f6b..55ad318 100644 --- a/src/server.js +++ b/src/server.js @@ -40,6 +40,24 @@ async function main() { } }); + app.get('/api/containers/:containerId/mounts', async (request, response) => { + try { + const inspect = await dockerService.inspectContainer(request.params.containerId); + const mounts = (inspect.Mounts || []) + .filter((m) => m.Type === 'bind' || m.Type === 'volume') + .map((m) => ({ + type: m.Type, + name: m.Name || null, + source: m.Source, + destination: m.Destination, + rw: m.RW, + })); + response.json(mounts); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + app.get('/api/profiles', async (_request, response) => { try { const profiles = await store.listProfiles(); @@ -71,6 +89,7 @@ async function main() { containerIds: payload.containerIds, mode: existing?.mode || 'full', backupScope: payload.backupScope || existing?.backupScope || 'volumes', + volumeSelections: payload.volumeSelections || existing?.volumeSelections || {}, }); response.status(payload.id ? 200 : 201).json(profile); diff --git a/src/store.js b/src/store.js index 457bbe1..a418c83 100644 --- a/src/store.js +++ b/src/store.js @@ -54,6 +54,7 @@ class JsonStore { containerIds: profileInput.containerIds, mode: profileInput.mode, backupScope: profileInput.backupScope || 'volumes', + volumeSelections: profileInput.volumeSelections || {}, backupDir: profileInput.backupDir, updatedAt: now, createdAt: profileInput.createdAt || now,