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: [],
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue