atualiza versão para 0.2.5; adiciona visualizador de snapshot de backup, extração seletiva de arquivos e busca de arquivos no snapshot
This commit is contained in:
parent
c6c0db01f1
commit
dac4c98e18
15
README.md
15
README.md
|
|
@ -9,7 +9,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/VERSION-0.2.2-blue?style=flat-square" />
|
<img src="https://img.shields.io/badge/VERSION-0.2.5-blue?style=flat-square" />
|
||||||
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
|
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
|
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
|
||||||
|
|
@ -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.
|
> ⚠️ **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
|
## 📋 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
|
### [0.2.2] — 2026-05-12
|
||||||
|
|
||||||
#### Corrigido
|
#### Corrigido
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dockerbackup-app",
|
"name": "dockerbackup-app",
|
||||||
"version": "0.2.2",
|
"version": "0.2.5",
|
||||||
"description": "Aplicacao web para backup e restauracao de volumes Docker",
|
"description": "Aplicacao web para backup e restauracao de volumes Docker",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
237
public/app.js
237
public/app.js
|
|
@ -119,6 +119,17 @@ const elements = {
|
||||||
sourceFormPort: document.querySelector('#sourceFormPort'),
|
sourceFormPort: document.querySelector('#sourceFormPort'),
|
||||||
sourcesList: document.querySelector('#sourcesList'),
|
sourcesList: document.querySelector('#sourcesList'),
|
||||||
profileSourceSelect: document.querySelector('#profileSourceId'),
|
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 ──────────────────────────────────────
|
// ─── View navigation ──────────────────────────────────────
|
||||||
|
|
@ -776,6 +787,220 @@ function renderServers() {
|
||||||
// Servers view was removed
|
// 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) => `
|
||||||
|
<button class="snapshot-tab${i === 0 ? ' active' : ''}"
|
||||||
|
data-container-id="${escapeHtml(c.containerId)}">${escapeHtml(c.containerName)}</button>
|
||||||
|
`).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 = `<div class="snapshot-empty">Erro ao carregar: ${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="snapshot-empty">Nenhum arquivo encontrado.</div>';
|
||||||
|
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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" style="flex-shrink:0"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
|
||||||
|
const rootIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" style="flex-shrink:0"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`;
|
||||||
|
|
||||||
|
elements.snapshotFileList.innerHTML = sortedGroups.map(([dir, dirFiles]) => {
|
||||||
|
const header = dir
|
||||||
|
? `<div class="snapshot-dir-header">${folderIcon}<span>${escapeHtml(dir)}/</span></div>`
|
||||||
|
: `<div class="snapshot-dir-header">${rootIcon}<span>(raiz)</span></div>`;
|
||||||
|
|
||||||
|
const rows = dirFiles.map((file) => {
|
||||||
|
const shortName = dir ? file.name.slice(dir.length + 1) : file.name;
|
||||||
|
return `<label class="snapshot-file-item">
|
||||||
|
<input type="checkbox" name="snapshotFile" value="${escapeHtml(file.name)}" />
|
||||||
|
<span class="snapshot-file-name" title="${escapeHtml(file.name)}">${escapeHtml(shortName)}</span>
|
||||||
|
<span class="snapshot-file-size">${formatBytes(file.size || 0)}</span>
|
||||||
|
<span class="snapshot-file-mtime">${escapeHtml(file.mtime || '')}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<div class="snapshot-dir-group">${header}${rows}</div>`;
|
||||||
|
}).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() {
|
async function renderBackupsView() {
|
||||||
const host = document.querySelector('#backupsViewList');
|
const host = document.querySelector('#backupsViewList');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
@ -835,6 +1060,13 @@ function renderBackupRow(b, profile, isFull) {
|
||||||
<td><span class="status-badge status-badge--${escapeHtml(b.status)}">${escapeHtml(b.status)}</span></td>
|
<td><span class="status-badge status-badge--${escapeHtml(b.status)}">${escapeHtml(b.status)}</span></td>
|
||||||
<td>${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))}</td>
|
<td>${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn--secondary btn--sm btn--icon"
|
||||||
|
data-action="browse-backup"
|
||||||
|
data-profile-id="${escapeHtml(profile.id)}"
|
||||||
|
data-backup-id="${escapeHtml(b.id)}"
|
||||||
|
title="Ver arquivos do backup"
|
||||||
|
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></button>
|
||||||
<button
|
<button
|
||||||
class="btn btn--secondary btn--sm"
|
class="btn btn--secondary btn--sm"
|
||||||
data-action="restore"
|
data-action="restore"
|
||||||
|
|
@ -1722,6 +1954,11 @@ async function handleProfileAction(event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'browse-backup') {
|
||||||
|
openSnapshotModal(backupId, profileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'restore') {
|
if (action === 'restore') {
|
||||||
if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
|
if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,34 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Snapshot/browse de arquivos do backup -->
|
||||||
|
<div id="snapshotModal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" data-action="close-snapshot-modal"></div>
|
||||||
|
<div class="modal-card modal-card--wide modal-card--snapshot" role="dialog" aria-modal="true" aria-labelledby="snapshotModalTitle">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3 id="snapshotModalTitle">Snapshot do Backup</h3>
|
||||||
|
<p id="snapshotModalSubtitle" class="modal-subtitle"></p>
|
||||||
|
</div>
|
||||||
|
<button id="snapshotModalClose" class="btn btn--ghost btn--sm" type="button">Fechar</button>
|
||||||
|
</div>
|
||||||
|
<div id="snapshotContainerTabs" class="snapshot-tabs hidden"></div>
|
||||||
|
<div class="snapshot-toolbar">
|
||||||
|
<input id="snapshotSearch" type="search" class="snapshot-search" placeholder="Filtrar arquivos..." />
|
||||||
|
<span id="snapshotStats" class="snapshot-stats"></span>
|
||||||
|
</div>
|
||||||
|
<div id="snapshotLoading" class="snapshot-loading hidden">
|
||||||
|
<span class="spinner-inline"></span> Carregando arquivos do archive...
|
||||||
|
</div>
|
||||||
|
<div id="snapshotFileList" class="snapshot-file-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="snapshotSelectAll" class="btn btn--secondary" type="button">Selecionar todos</button>
|
||||||
|
<button id="snapshotExtract" class="btn btn--primary" type="button" disabled>Extrair selecionados</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="profileFormModal" class="modal hidden" aria-hidden="true">
|
<div id="profileFormModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-backdrop" data-action="close-profile-modal"></div>
|
<div class="modal-backdrop" data-action="close-profile-modal"></div>
|
||||||
<div class="modal-card modal-card--wide" role="dialog" aria-modal="true" aria-labelledby="profileModalTitle">
|
<div class="modal-card modal-card--wide" role="dialog" aria-modal="true" aria-labelledby="profileModalTitle">
|
||||||
|
|
|
||||||
|
|
@ -1415,6 +1415,164 @@ button, input, select {
|
||||||
max-height: min(90vh, 800px);
|
max-height: min(90vh, 800px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Snapshot modal ───────────────────────────────────── */
|
||||||
|
.modal-card--snapshot {
|
||||||
|
max-height: min(90vh, 800px);
|
||||||
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-tab {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card, var(--surface));
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-tab:hover { background: var(--hover, rgba(0,0,0,0.04)); }
|
||||||
|
.snapshot-tab.active { background: var(--accent, #4a6cf7); color: #fff; border-color: var(--accent, #4a6cf7); }
|
||||||
|
|
||||||
|
.snapshot-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-search {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card, var(--surface));
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-stats {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-inline {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent, #4a6cf7);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.snapshot-file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 420px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-empty {
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-dir-group { }
|
||||||
|
.snapshot-dir-group + .snapshot-dir-group { border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.snapshot-dir-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg, rgba(0,0,0,0.03));
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 10px 4px 26px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-light, rgba(0,0,0,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-file-item:last-child { border-bottom: none; }
|
||||||
|
.snapshot-file-item:hover { background: var(--hover, rgba(0,0,0,0.03)); }
|
||||||
|
.snapshot-file-item input[type="checkbox"] { width: 13px; height: 13px; flex-shrink: 0; cursor: pointer; }
|
||||||
|
|
||||||
|
.snapshot-file-name {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-file-size {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-file-mtime {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 110px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* icon-only button */
|
||||||
|
.btn--icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn--icon svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
||||||
140
src/server.js
140
src/server.js
|
|
@ -2,8 +2,9 @@ const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs/promises');
|
const fs = require('fs/promises');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { execFile } = require('child_process');
|
const { execFile, spawn } = require('child_process');
|
||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|
@ -975,6 +976,143 @@ async function main() {
|
||||||
setInterval(() => runScheduledJobs().catch(console.error), 60_000);
|
setInterval(() => runScheduledJobs().catch(console.error), 60_000);
|
||||||
setTimeout(() => runScheduledJobs().catch(console.error), 10_000);
|
setTimeout(() => runScheduledJobs().catch(console.error), 10_000);
|
||||||
|
|
||||||
|
// ─── Backup file browser ──────────────────────────────────
|
||||||
|
function resolveArchivePath(profile, containerBackup) {
|
||||||
|
if (!profile || !profile.backupDir || !containerBackup || !containerBackup.archiveRelativePath) return null;
|
||||||
|
const parts = containerBackup.archiveRelativePath.split('/');
|
||||||
|
return path.join(profile.backupDir, ...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTarFiles(archivePath) {
|
||||||
|
const { stdout } = await execFileAsync('tar', ['-tvzf', archivePath], { maxBuffer: 100 * 1024 * 1024 });
|
||||||
|
const files = [];
|
||||||
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
// GNU tar verbose: permissions owner size date time name
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
if (parts.length < 6) continue;
|
||||||
|
const perms = parts[0];
|
||||||
|
const size = parseInt(parts[2], 10) || 0;
|
||||||
|
const mtime = `${parts[3]} ${parts[4]}`;
|
||||||
|
const name = parts.slice(5).join(' ').replace(/^\.\//, '');
|
||||||
|
if (!name || name === '.' || name.endsWith('/')) continue;
|
||||||
|
files.push({ name, size, isDir: perms.startsWith('d'), mtime });
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/backups/:backupId/containers/:containerId/files
|
||||||
|
app.get('/api/backups/:backupId/containers/:containerId/files', authMiddleware, async (request, response) => {
|
||||||
|
try {
|
||||||
|
const backup = await store.getBackup(request.params.backupId);
|
||||||
|
if (!backup) { response.status(404).json({ error: 'Backup não encontrado.' }); return; }
|
||||||
|
|
||||||
|
const profile = await store.getProfile(backup.profileId);
|
||||||
|
if (!profile) { response.status(404).json({ error: 'Profile não encontrado.' }); return; }
|
||||||
|
|
||||||
|
const containerId = request.params.containerId;
|
||||||
|
const chain = await store.getBackupsForContainer(backup.profileId, containerId, request.params.backupId);
|
||||||
|
if (!chain.length) { response.status(404).json({ error: 'Container não encontrado neste backup.' }); return; }
|
||||||
|
|
||||||
|
// Merge file listings across chain (later archives overwrite earlier ones for same path)
|
||||||
|
const fileMap = new Map();
|
||||||
|
for (const cb of chain) {
|
||||||
|
const archivePath = resolveArchivePath(profile, cb);
|
||||||
|
if (!archivePath) continue;
|
||||||
|
try {
|
||||||
|
const files = await listTarFiles(archivePath);
|
||||||
|
for (const file of files) fileMap.set(file.name, file);
|
||||||
|
} catch { /* archive not accessible */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [...fileMap.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
response.json({ files, chainLength: chain.length });
|
||||||
|
} catch (error) {
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/backups/:backupId/containers/:containerId/extract
|
||||||
|
app.post('/api/backups/:backupId/containers/:containerId/extract', authMiddleware, async (request, response) => {
|
||||||
|
let tmpDir = null;
|
||||||
|
try {
|
||||||
|
const selectedPaths = request.body?.paths;
|
||||||
|
if (!Array.isArray(selectedPaths) || !selectedPaths.length) {
|
||||||
|
response.status(400).json({ error: 'Selecione ao menos um arquivo para extrair.' }); return;
|
||||||
|
}
|
||||||
|
// Sanitize: strip leading slashes, null bytes, traversal sequences
|
||||||
|
const safePaths = selectedPaths
|
||||||
|
.map((p) => String(p).replace(/\0/g, '').replace(/^\/+/, '').replace(/\.\.\//g, ''))
|
||||||
|
.filter((p) => p.length > 0 && !p.includes('\0'));
|
||||||
|
if (!safePaths.length) { response.status(400).json({ error: 'Caminhos inválidos.' }); return; }
|
||||||
|
|
||||||
|
const backup = await store.getBackup(request.params.backupId);
|
||||||
|
if (!backup) { response.status(404).json({ error: 'Backup não encontrado.' }); return; }
|
||||||
|
|
||||||
|
const profile = await store.getProfile(backup.profileId);
|
||||||
|
if (!profile) { response.status(404).json({ error: 'Profile não encontrado.' }); return; }
|
||||||
|
|
||||||
|
const containerId = request.params.containerId;
|
||||||
|
const chain = await store.getBackupsForContainer(backup.profileId, containerId, request.params.backupId);
|
||||||
|
if (!chain.length) { response.status(404).json({ error: 'Container não encontrado neste backup.' }); return; }
|
||||||
|
|
||||||
|
// For each selected path, find latest archive in chain containing it
|
||||||
|
const archiveExtractMap = new Map(); // archivePath → [paths]
|
||||||
|
for (const selectedPath of safePaths) {
|
||||||
|
for (let i = chain.length - 1; i >= 0; i--) {
|
||||||
|
const archivePath = resolveArchivePath(profile, chain[i]);
|
||||||
|
if (!archivePath) continue;
|
||||||
|
try {
|
||||||
|
const files = await listTarFiles(archivePath);
|
||||||
|
const found = files.some((f) => f.name === selectedPath || f.name.startsWith(selectedPath + '/'));
|
||||||
|
if (found) {
|
||||||
|
if (!archiveExtractMap.has(archivePath)) archiveExtractMap.set(archivePath, []);
|
||||||
|
archiveExtractMap.get(archivePath).push(selectedPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { continue; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!archiveExtractMap.size) {
|
||||||
|
response.status(404).json({ error: 'Arquivos não encontrados nos archives.' }); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir = path.join(os.tmpdir(), `dbkp-extract-${crypto.randomUUID()}`);
|
||||||
|
await fs.mkdir(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const [archivePath, pathsToExtract] of archiveExtractMap) {
|
||||||
|
await execFileAsync('tar', ['-xzf', archivePath, '-C', tmpDir, '--', ...pathsToExtract], {
|
||||||
|
maxBuffer: 500 * 1024 * 1024,
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerName = (chain[0]?.containerName || containerId.slice(0, 12)).replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
const filename = `extract-${containerName}.tar.gz`;
|
||||||
|
|
||||||
|
response.setHeader('Content-Type', 'application/gzip');
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
|
const tarProc = spawn('tar', ['-czf', '-', '-C', tmpDir, '.'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||||
|
tarProc.stdout.pipe(response);
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
if (tmpDir) {
|
||||||
|
const dirToRemove = tmpDir;
|
||||||
|
tmpDir = null;
|
||||||
|
await fs.rm(dirToRemove, { recursive: true, force: true }).catch(() => null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tarProc.on('close', cleanup);
|
||||||
|
tarProc.on('error', cleanup);
|
||||||
|
response.on('close', cleanup);
|
||||||
|
} catch (error) {
|
||||||
|
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => null);
|
||||||
|
if (!response.headersSent) response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
|
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue