From dac4c98e184f2223ca271584bf390027c8a6f96d Mon Sep 17 00:00:00 2001
From: Alexander Sabino <32822107+asabino2@users.noreply.github.com>
Date: Tue, 12 May 2026 11:13:08 +0100
Subject: [PATCH] =?UTF-8?q?atualiza=20vers=C3=A3o=20para=200.2.5;=20adicio?=
=?UTF-8?q?na=20visualizador=20de=20snapshot=20de=20backup,=20extra=C3=A7?=
=?UTF-8?q?=C3=A3o=20seletiva=20de=20arquivos=20e=20busca=20de=20arquivos?=
=?UTF-8?q?=20no=20snapshot?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 15 ++-
package.json | 2 +-
public/app.js | 237 ++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 28 ++++++
public/styles.css | 158 +++++++++++++++++++++++++++++++
src/server.js | 140 ++++++++++++++++++++++++++-
6 files changed, 576 insertions(+), 4 deletions(-)
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
+ ? ``
+ : ``;
+
+ 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(', '))} |
+
|