Adiciona seleção de volumes para backup e implementa modal de escolha

This commit is contained in:
Alexander Sabino 2026-05-04 18:46:43 +01:00
parent f354c66f5a
commit f34ce0ead3
6 changed files with 250 additions and 9 deletions

View File

@ -2,6 +2,7 @@ const state = {
containers: [], containers: [],
profiles: [], profiles: [],
activeRuns: new Map(), activeRuns: new Map(),
volumeSelections: {},
}; };
const elements = { const elements = {
@ -21,6 +22,12 @@ const elements = {
restoreModalConfirm: document.querySelector('#restoreModalConfirm'), restoreModalConfirm: document.querySelector('#restoreModalConfirm'),
restoreModalClose: document.querySelector('#restoreModalClose'), restoreModalClose: document.querySelector('#restoreModalClose'),
restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'), 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 = {}) { async function api(path, options = {}) {
@ -70,25 +77,38 @@ function getSelectedContainerIds() {
return [...document.querySelectorAll('input[name="containerIds"]:checked')].map((input) => input.value); 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() { function renderContainers() {
elements.containerCount.textContent = String(state.containers.length); 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 = '<p class="empty-state">Nenhum container encontrado.</p>'; elements.containerOptions.innerHTML = '<p class="empty-state">Nenhum container encontrado.</p>';
return; return;
} }
const selected = new Set(getSelectedContainerIds()); 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 `
<label class="container-option"> <label class="container-option">
<input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} /> <input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} />
<span> <span>
<strong>${escapeHtml(container.name)}</strong> <strong>${escapeHtml(container.name)}</strong>
<small>${escapeHtml(container.image)} · ${escapeHtml(container.status)}</small> <small>${escapeHtml(container.image)} · ${escapeHtml(container.status)}${hasVolumeSelection ? ` · ${state.volumeSelections[container.id].length} volume(s) selecionado(s)` : ''}</small>
</span> </span>
<em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em> <em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em>
</label> </label>
`).join(''); `;
}).join('');
} }
function backupButtons(profile) { function backupButtons(profile) {
@ -121,6 +141,141 @@ function getProfileScopeLabel(scope) {
return scope === 'container' ? 'container inteiro' : 'somente volumes'; 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 `
<label class="modal-option${blocked ? ' volume-blocked' : ''}">
<input
type="checkbox"
name="volumePaths"
value="${escapeHtml(mount.destination)}"
${isChecked && !blocked ? 'checked' : ''}
${blocked ? 'disabled' : ''}
/>
<span>
<strong>${escapeHtml(mount.destination)}</strong>
<small>${escapeHtml(mount.type)} · ${escapeHtml(mount.source || mount.name || '')}${blocked ? ' · sistema (bloqueado)' : ''}</small>
</span>
</label>
`;
}).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) { function askRestoreContainerSelection(profile, backup) {
const restorable = (backup.containers || []).filter((item) => item.status === 'ok'); const restorable = (backup.containers || []).filter((item) => item.status === 'ok');
if (!restorable.length) { if (!restorable.length) {
@ -382,6 +537,7 @@ function resetForm() {
elements.profileForm.reset(); elements.profileForm.reset();
elements.profileId.value = ''; elements.profileId.value = '';
elements.formModeBadge.textContent = 'criar'; elements.formModeBadge.textContent = 'criar';
state.volumeSelections = {};
renderContainers(); renderContainers();
} }
@ -392,6 +548,7 @@ function fillForm(profile) {
const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes'; const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true; document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
elements.formModeBadge.textContent = 'editar'; elements.formModeBadge.textContent = 'editar';
state.volumeSelections = Object.assign({}, profile.volumeSelections || {});
renderContainers(); renderContainers();
for (const containerId of profile.containerIds) { for (const containerId of profile.containerIds) {
const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`); const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`);
@ -414,12 +571,25 @@ async function loadProfiles() {
async function saveProfile(event) { async function saveProfile(event) {
event.preventDefault(); 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 = { const payload = {
id: elements.profileId.value || undefined, id: elements.profileId.value || undefined,
name: elements.profileName.value, name: elements.profileName.value,
backupDir: elements.backupDir.value, backupDir: elements.backupDir.value,
containerIds: getSelectedContainerIds(), containerIds: selectedContainerIds,
backupScope: document.querySelector('input[name="backupScope"]:checked').value, backupScope,
volumeSelections,
}; };
try { try {
@ -588,6 +758,7 @@ async function init() {
} }
elements.profileForm.addEventListener('submit', saveProfile); elements.profileForm.addEventListener('submit', saveProfile);
elements.containerOptions.addEventListener('change', handleContainerCheck);
document.querySelector('#refreshContainers').addEventListener('click', init); document.querySelector('#refreshContainers').addEventListener('click', init);
document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles); document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles);
document.querySelector('#clearForm').addEventListener('click', resetForm); document.querySelector('#clearForm').addEventListener('click', resetForm);

View File

@ -108,6 +108,21 @@
</div> </div>
</div> </div>
</div> </div>
<div id="volumePickerModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-volume-picker"></div>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="volumePickerTitle">
<div class="modal-header">
<h3 id="volumePickerTitle">Selecionar volumes para backup</h3>
<button id="volumePickerClose" class="ghost-button small" type="button" data-action="close-volume-picker">Fechar</button>
</div>
<p id="volumePickerSubtitle" class="modal-subtitle"></p>
<div id="volumePickerOptions" class="modal-options"></div>
<div class="modal-actions">
<button id="volumePickerSelectAll" class="secondary-button" type="button">Marcar todos</button>
<button id="volumePickerConfirm" class="primary-button" type="button">Confirmar seleção</button>
</div>
</div>
</div>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>

View File

@ -503,6 +503,17 @@ input[type='text'] {
color: var(--muted); 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 { .modal-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -306,11 +306,35 @@ class BackupService {
const archiveRelativePath = path.posix.join(safeProfileName, safeContainerName, `${stamp}-${runMode}.tar.gz`); const archiveRelativePath = path.posix.join(safeProfileName, safeContainerName, `${stamp}-${runMode}.tar.gz`);
const snapshotRelativePath = path.posix.join(safeProfileName, safeContainerName, 'latest.snar'); 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 = { const containerBackup = {
containerId: inspect.Id, containerId: inspect.Id,
containerName, containerName,
backupScope, backupScope,
backupPaths: backupScope === 'container' ? ['/'] : mounts.map((mount) => mount.destination), backupPaths: backupScope === 'container' ? ['/'] : activeMounts.map((mount) => mount.destination),
mountSignature: mounts, mountSignature: mounts,
archiveRelativePath, archiveRelativePath,
snapshotRelativePath, snapshotRelativePath,
@ -378,7 +402,7 @@ class BackupService {
const sourcePaths = backupScope === 'container' const sourcePaths = backupScope === 'container'
? ['/'] ? ['/']
: mounts.map((mount) => mount.destination); : activeMounts.map((mount) => mount.destination);
const relSourcePaths = sourcePaths.map((item) => toContainerRelPath(item)); const relSourcePaths = sourcePaths.map((item) => toContainerRelPath(item));
if (backupScope === 'volumes') { if (backupScope === 'volumes') {
@ -480,7 +504,7 @@ class BackupService {
} }
const binds = [`${backupRoot}:/backuproot`]; 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`); binds.push(`${getMountBindingSource(mount)}:/payload/m${index}:ro`);
} }

View File

@ -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) => { app.get('/api/profiles', async (_request, response) => {
try { try {
const profiles = await store.listProfiles(); const profiles = await store.listProfiles();
@ -71,6 +89,7 @@ async function main() {
containerIds: payload.containerIds, containerIds: payload.containerIds,
mode: existing?.mode || 'full', mode: existing?.mode || 'full',
backupScope: payload.backupScope || existing?.backupScope || 'volumes', backupScope: payload.backupScope || existing?.backupScope || 'volumes',
volumeSelections: payload.volumeSelections || existing?.volumeSelections || {},
}); });
response.status(payload.id ? 200 : 201).json(profile); response.status(payload.id ? 200 : 201).json(profile);

View File

@ -54,6 +54,7 @@ class JsonStore {
containerIds: profileInput.containerIds, containerIds: profileInput.containerIds,
mode: profileInput.mode, mode: profileInput.mode,
backupScope: profileInput.backupScope || 'volumes', backupScope: profileInput.backupScope || 'volumes',
volumeSelections: profileInput.volumeSelections || {},
backupDir: profileInput.backupDir, backupDir: profileInput.backupDir,
updatedAt: now, updatedAt: now,
createdAt: profileInput.createdAt || now, createdAt: profileInput.createdAt || now,