diff --git a/README.md b/README.md index 966bc53..5e7cb33 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + @@ -18,12 +18,23 @@ > ⚠️ **AVISO CRÍTICO:** Aplicação em estágio inicial de desenvolvimento. Não use em produção — há risco de perda de dados. -Versão atual: **0.2.2** +Versão atual: **0.2.5** --- ## 📋 Changelog +### [0.2.5] — 2026-05-12 + +#### Adicionado +- **Visualizador de snapshot de backup:** botão de ícone em cada linha de backup abre um modal com a lista completa de arquivos do archive, refletindo o estado final após restore (incluindo cadeia full + incrementais). +- **Extração seletiva de arquivos:** dentro do modal de snapshot é possível selecionar um ou mais arquivos individualmente (ou todos de uma vez) e baixá-los como `.tar.gz`. +- **Busca de arquivos no snapshot:** campo de filtro em tempo real para localizar arquivos pelo nome dentro do modal. +- **Abas por container:** quando um backup contém múltiplos containers, o modal exibe abas para navegar entre eles. +- **Suporte à cadeia incremental:** a listagem de arquivos mescla automaticamente full + incrementais, garantindo que a visão do snapshot corresponda ao estado real restaurado. + +--- + ### [0.2.2] — 2026-05-12 #### Corrigido diff --git a/package.json b/package.json index f0deff6..f99d347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dockerbackup-app", - "version": "0.2.2", + "version": "0.2.5", "description": "Aplicacao web para backup e restauracao de volumes Docker", "main": "src/server.js", "scripts": { diff --git a/public/app.js b/public/app.js index 2f46175..e0d13c4 100644 --- a/public/app.js +++ b/public/app.js @@ -119,6 +119,17 @@ const elements = { sourceFormPort: document.querySelector('#sourceFormPort'), sourcesList: document.querySelector('#sourcesList'), profileSourceSelect: document.querySelector('#profileSourceId'), + // snapshot modal + snapshotModal: document.querySelector('#snapshotModal'), + snapshotModalSubtitle: document.querySelector('#snapshotModalSubtitle'), + snapshotContainerTabs: document.querySelector('#snapshotContainerTabs'), + snapshotSearch: document.querySelector('#snapshotSearch'), + snapshotStats: document.querySelector('#snapshotStats'), + snapshotLoading: document.querySelector('#snapshotLoading'), + snapshotFileList: document.querySelector('#snapshotFileList'), + snapshotSelectAll: document.querySelector('#snapshotSelectAll'), + snapshotExtract: document.querySelector('#snapshotExtract'), + snapshotModalClose: document.querySelector('#snapshotModalClose'), }; // ─── View navigation ────────────────────────────────────── @@ -776,6 +787,220 @@ function renderServers() { // Servers view was removed } +// ─── Snapshot modal (browse de arquivos do backup) ─────────── + +const snapshotState = { + backup: null, + profile: null, + activeContainerId: null, + filesByContainer: {}, +}; + +async function openSnapshotModal(backupId, profileId) { + snapshotState.backup = null; + snapshotState.filesByContainer = {}; + snapshotState.activeContainerId = null; + + let backup; + try { + backup = await api(`/api/backups/${encodeURIComponent(backupId)}`); + } catch (error) { + showToast(`Erro ao carregar backup: ${error.message}`, true); + return; + } + + const profile = state.profiles.find((p) => p.id === profileId) || { id: profileId, name: profileId }; + snapshotState.backup = backup; + snapshotState.profile = profile; + + const modeLabel = backup.mode === 'full' ? 'Full' : 'Incremental'; + elements.snapshotModalSubtitle.textContent = + `${escapeHtml(profile.name)} — ${new Date(backup.createdAt).toLocaleString('pt-BR')} — ${modeLabel}`; + + const containers = (backup.containers || []).filter((c) => c.status === 'ok' && c.archiveRelativePath); + if (!containers.length) { + showToast('Backup sem containers com arquivos disponíveis.', true); + return; + } + + elements.snapshotSearch.value = ''; + elements.snapshotFileList.innerHTML = ''; + elements.snapshotStats.textContent = ''; + elements.snapshotExtract.disabled = true; + elements.snapshotExtract.textContent = 'Extrair selecionados'; + + // Build container tabs + if (containers.length > 1) { + elements.snapshotContainerTabs.classList.remove('hidden'); + elements.snapshotContainerTabs.innerHTML = containers.map((c, i) => ` + + `).join(''); + elements.snapshotContainerTabs.querySelectorAll('.snapshot-tab').forEach((btn) => { + btn.addEventListener('click', () => { + elements.snapshotContainerTabs.querySelectorAll('.snapshot-tab').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + loadSnapshotContainerFiles(btn.dataset.containerId); + }); + }); + } else { + elements.snapshotContainerTabs.classList.add('hidden'); + } + + elements.snapshotModal.classList.remove('hidden'); + elements.snapshotModal.setAttribute('aria-hidden', 'false'); + + await loadSnapshotContainerFiles(containers[0].containerId); +} + +async function loadSnapshotContainerFiles(containerId) { + snapshotState.activeContainerId = containerId; + elements.snapshotLoading.classList.remove('hidden'); + elements.snapshotFileList.innerHTML = ''; + elements.snapshotStats.textContent = ''; + elements.snapshotExtract.disabled = true; + elements.snapshotExtract.textContent = 'Extrair selecionados'; + elements.snapshotSearch.value = ''; + + try { + if (!snapshotState.filesByContainer[containerId]) { + const result = await api( + `/api/backups/${encodeURIComponent(snapshotState.backup.id)}/containers/${encodeURIComponent(containerId)}/files`, + ); + snapshotState.filesByContainer[containerId] = result; + } + elements.snapshotLoading.classList.add('hidden'); + renderSnapshotFiles(snapshotState.filesByContainer[containerId].files, ''); + } catch (error) { + elements.snapshotLoading.classList.add('hidden'); + elements.snapshotFileList.innerHTML = `

Erro ao carregar: ${escapeHtml(error.message)}
`; + } +} + +function renderSnapshotFiles(files, filter) { + const lc = filter.toLowerCase(); + const visible = filter ? files.filter((f) => !f.isDir && f.name.toLowerCase().includes(lc)) : files.filter((f) => !f.isDir); + + const fileCount = visible.length; + const totalSize = visible.reduce((s, f) => s + (f.size || 0), 0); + elements.snapshotStats.textContent = `${fileCount} arquivo(s) — ${formatBytes(totalSize)}`; + + if (!visible.length) { + elements.snapshotFileList.innerHTML = '
Nenhum arquivo encontrado.
'; + updateSnapshotExtractBtn(); + return; + } + + // Group by directory + const groups = new Map(); + for (const file of visible) { + const slash = file.name.lastIndexOf('/'); + const dir = slash >= 0 ? file.name.slice(0, slash) : ''; + if (!groups.has(dir)) groups.set(dir, []); + groups.get(dir).push(file); + } + + const sortedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)); + + const folderIcon = ``; + const rootIcon = ``; + + elements.snapshotFileList.innerHTML = sortedGroups.map(([dir, dirFiles]) => { + const header = dir + ? `
${folderIcon}${escapeHtml(dir)}/
` + : `
${rootIcon}(raiz)
`; + + const rows = dirFiles.map((file) => { + const shortName = dir ? file.name.slice(dir.length + 1) : file.name; + return ``; + }).join(''); + + return `
${header}${rows}
`; + }).join(''); + + elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]').forEach((cb) => { + cb.addEventListener('change', updateSnapshotExtractBtn); + }); + updateSnapshotExtractBtn(); +} + +function updateSnapshotExtractBtn() { + const checked = elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]:checked').length; + elements.snapshotExtract.disabled = checked === 0; + elements.snapshotExtract.textContent = checked > 0 ? `Extrair ${checked} arquivo(s)` : 'Extrair selecionados'; +} + +function closeSnapshotModal() { + elements.snapshotModal.classList.add('hidden'); + elements.snapshotModal.setAttribute('aria-hidden', 'true'); +} + +elements.snapshotModalClose?.addEventListener('click', closeSnapshotModal); +elements.snapshotModal?.querySelector('[data-action="close-snapshot-modal"]')?.addEventListener('click', closeSnapshotModal); + +elements.snapshotSearch?.addEventListener('input', () => { + const data = snapshotState.filesByContainer[snapshotState.activeContainerId]; + if (data) renderSnapshotFiles(data.files, elements.snapshotSearch.value); +}); + +elements.snapshotSelectAll?.addEventListener('click', () => { + const checkboxes = elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]:not(:disabled)'); + const allChecked = [...checkboxes].every((c) => c.checked); + checkboxes.forEach((c) => { c.checked = !allChecked; }); + updateSnapshotExtractBtn(); +}); + +elements.snapshotExtract?.addEventListener('click', async () => { + const checked = [...elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]:checked')].map((c) => c.value); + if (!checked.length) return; + + const containerId = snapshotState.activeContainerId; + const backupId = snapshotState.backup.id; + + elements.snapshotExtract.disabled = true; + elements.snapshotExtract.textContent = 'Preparando...'; + + try { + const token = localStorage.getItem('authToken'); + const resp = await fetch( + `/api/backups/${encodeURIComponent(backupId)}/containers/${encodeURIComponent(containerId)}/extract`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'x-auth-token': token } : {}), + }, + body: JSON.stringify({ paths: checked }), + }, + ); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ error: 'Erro ao extrair arquivos.' })); + throw new Error(err.error || 'Erro ao extrair arquivos.'); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const cd = resp.headers.get('Content-Disposition') || ''; + const fnMatch = cd.match(/filename="?([^"]+)"?/); + a.download = fnMatch ? fnMatch[1] : 'extract.tar.gz'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(`${checked.length} arquivo(s) extraído(s) com sucesso.`); + } catch (error) { + showToast(`Erro ao extrair: ${error.message}`, true); + } finally { + updateSnapshotExtractBtn(); + } +}); + async function renderBackupsView() { const host = document.querySelector('#backupsViewList'); if (!host) return; @@ -835,6 +1060,13 @@ function renderBackupRow(b, profile, isFull) { ${escapeHtml(b.status)} ${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))} + + + +
+ + +
+ +
+ + + +