Adiciona seleção de volumes para backup e implementa modal de escolha
This commit is contained in:
parent
f354c66f5a
commit
f34ce0ead3
183
public/app.js
183
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 = '<p class="empty-state">Nenhum container encontrado.</p>';
|
||||
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 `
|
||||
<label class="container-option">
|
||||
<input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} />
|
||||
<span>
|
||||
<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>
|
||||
<em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em>
|
||||
</label>
|
||||
`).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 `
|
||||
<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) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,21 @@
|
|||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue