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 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/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
237
public/app.js
237
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) => `
|
||||
<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() {
|
||||
const host = document.querySelector('#backupsViewList');
|
||||
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>${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))}</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
|
||||
class="btn btn--secondary btn--sm"
|
||||
data-action="restore"
|
||||
|
|
@ -1722,6 +1954,11 @@ async function handleProfileAction(event) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (action === 'browse-backup') {
|
||||
openSnapshotModal(backupId, profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'restore') {
|
||||
if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -398,6 +398,34 @@
|
|||
</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 class="modal-backdrop" data-action="close-profile-modal"></div>
|
||||
<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);
|
||||
}
|
||||
|
||||
/* ─── 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 {
|
||||
display: flex;
|
||||
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 fs = require('fs/promises');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const { execFile, spawn } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const os = require('os');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -975,6 +976,143 @@ async function main() {
|
|||
setInterval(() => runScheduledJobs().catch(console.error), 60_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, () => {
|
||||
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue