first commit
This commit is contained in:
commit
f8e9aca195
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
data/*.json
|
||||
data/tmpnode_modules
|
||||
npm-debug.log
|
||||
data/store.json
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
data/
|
||||
node_modules/
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
#COPY data ./data
|
||||
|
||||
ENV PORT=3000
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV HELPER_IMAGE=dockerbackup-app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Docker Backup App
|
||||
|
||||
⚠️ **AVISO CRÍTICO:** Esta é uma aplicação em estágio inicial de desenvolvimento, não use em produção de forma alguma, há risco de perda de dados ⚠️
|
||||
|
||||
Aplicacao web para cadastrar profiles de backup de containers Docker, executar backup full ou incremental e restaurar snapshots de volumes e bind mounts.
|
||||
|
||||
## Como funciona
|
||||
|
||||
- O app lista os containers via Docker socket.
|
||||
- Cada backup processa um container por vez: para, executa o backup dos mounts elegiveis e sobe novamente se ele estava rodando.
|
||||
- O backup usa um container auxiliar com GNU tar e `--listed-incremental` para gerar arquivos compactados `.tar.gz`.
|
||||
- Quando o app roda dentro de Docker, backup e restore sao feitos via Docker API (`getArchive`/`putArchive`) sem criar helper e sem mapear o root do host.
|
||||
- Ha dois escopos por profile: `somente volumes` (comportamento tradicional) e `container inteiro` (tar unico por container a partir de `/`).
|
||||
- O restore aplica a cadeia full + incrementais sobre os mounts atuais do container, limpando o conteudo antes de reconstituir o snapshot escolhido.
|
||||
- Ao restaurar um backup, e possivel escolher quais containers do backup serao restaurados.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Docker Engine com acesso ao socket em `/var/run/docker.sock`.
|
||||
- O diretorio de backup informado no profile precisa ser visivel para o Docker daemon.
|
||||
- Em Docker Desktop no Windows, quando o app roda fora de container, paths como `C:\backups` sao convertidos automaticamente para `/run/desktop/mnt/host/c/backups`.
|
||||
- Quando o app roda dentro de container, use um caminho absoluto interno do container (ex.: `/app/data/backups`).
|
||||
- O escopo `container inteiro` exige que o app esteja rodando em Docker para usar backup/restore nativos sem helper.
|
||||
|
||||
## Executando com Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Abra `http://localhost:3000`.
|
||||
|
||||
O arquivo `docker-compose.example.yml` foi mantido como referencia equivalente ao compose principal.
|
||||
|
||||
## Observacoes
|
||||
|
||||
- O restore valida se o conjunto de mounts do container continua igual ao do backup selecionado.
|
||||
- O catalogo de profiles e historico de backups fica salvo em `./data/store.json`.
|
||||
- Os arquivos `.tar.gz` sao gravados no diretorio de backup configurado em cada profile.
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
services:
|
||||
dockerbackup:
|
||||
image: dockerbackup-app
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
PORT: 3000
|
||||
DATA_DIR: /app/data
|
||||
HELPER_IMAGE: dockerbackup-app
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
restart: unless-stopped
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
services:
|
||||
dockerbackup:
|
||||
image: dockerbackup-app
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "33000:3000"
|
||||
environment:
|
||||
PORT: 3000
|
||||
DATA_DIR: /app/data
|
||||
HELPER_IMAGE: dockerbackup-app
|
||||
volumes:
|
||||
- /mnt/f/ChromeDownloads/dockerbackup/data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# restart: unless-stopped
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "dockerbackup-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Aplicacao web para backup e restauracao de volumes Docker",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"check": "node --check src/server.js && node --check src/config.js && node --check src/dockerService.js && node --check src/store.js && node --check src/backupService.js && node --check public/app.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockerode": "^4.0.7",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,596 @@
|
|||
const state = {
|
||||
containers: [],
|
||||
profiles: [],
|
||||
activeRuns: new Map(),
|
||||
};
|
||||
|
||||
const elements = {
|
||||
containerCount: document.querySelector('#containerCount'),
|
||||
profileCount: document.querySelector('#profileCount'),
|
||||
profileForm: document.querySelector('#profileForm'),
|
||||
profileId: document.querySelector('#profileId'),
|
||||
profileName: document.querySelector('#profileName'),
|
||||
backupDir: document.querySelector('#backupDir'),
|
||||
containerOptions: document.querySelector('#containerOptions'),
|
||||
profilesList: document.querySelector('#profilesList'),
|
||||
formModeBadge: document.querySelector('#formModeBadge'),
|
||||
toast: document.querySelector('#toast'),
|
||||
restoreModal: document.querySelector('#restoreModal'),
|
||||
restoreModalSubtitle: document.querySelector('#restoreModalSubtitle'),
|
||||
restoreContainerOptions: document.querySelector('#restoreContainerOptions'),
|
||||
restoreModalConfirm: document.querySelector('#restoreModalConfirm'),
|
||||
restoreModalClose: document.querySelector('#restoreModalClose'),
|
||||
restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'),
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Falha na requisicao');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function showToast(message, isError = false) {
|
||||
elements.toast.textContent = message;
|
||||
elements.toast.classList.remove('hidden', 'error');
|
||||
if (isError) {
|
||||
elements.toast.classList.add('error');
|
||||
}
|
||||
|
||||
window.clearTimeout(showToast.timer);
|
||||
showToast.timer = window.setTimeout(() => {
|
||||
elements.toast.classList.add('hidden');
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
function getSelectedContainerIds() {
|
||||
return [...document.querySelectorAll('input[name="containerIds"]:checked')].map((input) => input.value);
|
||||
}
|
||||
|
||||
function renderContainers() {
|
||||
elements.containerCount.textContent = String(state.containers.length);
|
||||
|
||||
if (!state.containers.length) {
|
||||
elements.containerOptions.innerHTML = '<p class="empty-state">Nenhum container encontrado.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = new Set(getSelectedContainerIds());
|
||||
elements.containerOptions.innerHTML = state.containers.map((container) => `
|
||||
<label class="container-option">
|
||||
<input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} />
|
||||
<span>
|
||||
<strong>${escapeHtml(container.name)}</strong>
|
||||
<small>${escapeHtml(container.image)} · ${escapeHtml(container.status)}</small>
|
||||
</span>
|
||||
<em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function backupButtons(profile) {
|
||||
const isRunning = state.activeRuns.has(profile.id);
|
||||
return `
|
||||
<div class="run-actions">
|
||||
<label class="mode-picker" for="runMode-${escapeHtml(profile.id)}">
|
||||
<span>Modo do backup</span>
|
||||
<select id="runMode-${escapeHtml(profile.id)}" data-run-mode="${escapeHtml(profile.id)}" class="mode-select" ${isRunning ? 'disabled' : ''}>
|
||||
<option value="full">Full</option>
|
||||
<option value="incremental">Incremental</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="card-actions">
|
||||
<button data-action="run" data-profile-id="${escapeHtml(profile.id)}" class="primary-button small" ${isRunning ? 'disabled' : ''}>${isRunning ? 'Executando...' : 'Run'}</button>
|
||||
<button data-action="edit" data-profile-id="${escapeHtml(profile.id)}" class="secondary-button small">Editar</button>
|
||||
<button data-action="delete" data-profile-id="${escapeHtml(profile.id)}" class="ghost-button small">Excluir</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getRunMode(profileId) {
|
||||
const selector = document.querySelector(`select[data-run-mode="${profileId}"]`);
|
||||
const value = selector?.value;
|
||||
return value === 'incremental' ? 'incremental' : 'full';
|
||||
}
|
||||
|
||||
function getProfileScopeLabel(scope) {
|
||||
return scope === 'container' ? 'container inteiro' : 'somente volumes';
|
||||
}
|
||||
|
||||
function askRestoreContainerSelection(profile, backup) {
|
||||
const restorable = (backup.containers || []).filter((item) => item.status === 'ok');
|
||||
if (!restorable.length) {
|
||||
throw new Error('Nao ha containers validos neste backup para restaurar.');
|
||||
}
|
||||
|
||||
elements.restoreModalSubtitle.textContent = `${profile.name} - ${new Date(backup.createdAt).toLocaleString('pt-BR')}`;
|
||||
elements.restoreContainerOptions.innerHTML = restorable.map((item) => `
|
||||
<label class="modal-option">
|
||||
<input type="checkbox" name="restoreContainerIds" value="${escapeHtml(item.containerId)}" checked />
|
||||
<span>
|
||||
<strong>${escapeHtml(item.containerName)}</strong>
|
||||
<small>${escapeHtml(item.status)}</small>
|
||||
</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
elements.restoreModal.classList.remove('hidden');
|
||||
elements.restoreModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const closeModal = () => {
|
||||
elements.restoreModal.classList.add('hidden');
|
||||
elements.restoreModal.setAttribute('aria-hidden', 'true');
|
||||
elements.restoreContainerOptions.innerHTML = '';
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
elements.restoreModalConfirm.removeEventListener('click', onConfirm);
|
||||
elements.restoreModalClose.removeEventListener('click', onCancel);
|
||||
elements.restoreModalSelectAll.removeEventListener('click', onSelectAll);
|
||||
elements.restoreModal.removeEventListener('click', onBackdropClick);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
const selected = [...elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]:checked')]
|
||||
.map((input) => input.value);
|
||||
|
||||
if (!selected.length) {
|
||||
showToast('Selecione ao menos um container para restaurar.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
closeModal();
|
||||
resolve(selected);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
cleanup();
|
||||
closeModal();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onSelectAll = () => {
|
||||
for (const input of elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]')) {
|
||||
input.checked = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onBackdropClick = (event) => {
|
||||
const closeTrigger = event.target.closest('[data-action="close-restore-modal"]');
|
||||
if (closeTrigger) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
elements.restoreModalConfirm.addEventListener('click', onConfirm);
|
||||
elements.restoreModalClose.addEventListener('click', onCancel);
|
||||
elements.restoreModalSelectAll.addEventListener('click', onSelectAll);
|
||||
elements.restoreModal.addEventListener('click', onBackdropClick);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBackupFailures(backup) {
|
||||
const failures = (backup.containers || []).filter((item) => item.status === 'error');
|
||||
if (!failures.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<small class="backup-error">
|
||||
Falhas: ${failures.map((item) => `${escapeHtml(item.containerName || item.containerId || 'container')}: ${escapeHtml(item.error || 'erro desconhecido')}`).join(' | ')}
|
||||
</small>
|
||||
`;
|
||||
}
|
||||
|
||||
function progressBar(percent) {
|
||||
const normalized = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
return `
|
||||
<div class="progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${normalized}">
|
||||
<span class="progress-fill" style="width: ${normalized}%;"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRunProgress(profileId) {
|
||||
const run = state.activeRuns.get(profileId);
|
||||
const host = document.querySelector(`[data-run-progress="${profileId}"]`);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run || !run.progress) {
|
||||
host.innerHTML = '';
|
||||
host.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const overall = run.progress.overall || { total: 0, completed: 0, pending: 0, percent: 0 };
|
||||
const currentContainer = run.progress.currentContainer;
|
||||
const file = currentContainer?.file || { current: 0, total: 0, percent: 0, currentFile: null };
|
||||
const containerPercent = Number.isFinite(currentContainer?.percent) ? currentContainer.percent : 0;
|
||||
const stepLabel = currentContainer?.step || 'aguardando';
|
||||
const stepMessage = currentContainer?.message || 'Aguardando processamento de arquivo...';
|
||||
const logs = Array.isArray(currentContainer?.logs) ? currentContainer.logs.slice(-8) : [];
|
||||
const operation = run?.kind === 'restore' || run?.progress?.operation === 'restore' ? 'restore' : 'backup';
|
||||
const operationTitle = operation === 'restore' ? 'Progresso do restore' : 'Progresso do backup';
|
||||
|
||||
host.classList.remove('hidden');
|
||||
host.innerHTML = `
|
||||
<div class="progress-card">
|
||||
<div class="progress-header">
|
||||
<strong>${escapeHtml(operationTitle)}</strong>
|
||||
<small>${escapeHtml(run.status)}</small>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Containers: ${escapeHtml(String(overall.completed))}/${escapeHtml(String(overall.total))} concluido(s)</span>
|
||||
<span>Faltam ${escapeHtml(String(overall.pending))}</span>
|
||||
</div>
|
||||
${progressBar(overall.percent)}
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Container atual: ${escapeHtml(currentContainer?.containerName || currentContainer?.containerId || '-')}</span>
|
||||
<span>${escapeHtml(String(Math.round(containerPercent)))}%</span>
|
||||
</div>
|
||||
${progressBar(containerPercent)}
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Arquivos: ${escapeHtml(String(file.current || 0))}/${escapeHtml(String(file.total || 0))}</span>
|
||||
<span>${escapeHtml(String(Math.round(file.percent || 0)))}%</span>
|
||||
</div>
|
||||
${progressBar(file.percent || 0)}
|
||||
<small class="current-file">Etapa: ${escapeHtml(stepLabel)} · ${escapeHtml(file.currentFile || stepMessage)}</small>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Log detalhado</span>
|
||||
<span>${escapeHtml(String(logs.length))} evento(s)</span>
|
||||
</div>
|
||||
<div class="progress-log">
|
||||
${logs.length
|
||||
? logs.map((line) => `<small>${escapeHtml(line)}</small>`).join('')
|
||||
: '<small>Nenhum evento detalhado ainda.</small>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAllRunProgress() {
|
||||
for (const profile of state.profiles) {
|
||||
renderRunProgress(profile.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollRun(profileId, runId) {
|
||||
const doneStatus = new Set(['completed', 'completed-with-errors', 'error']);
|
||||
|
||||
while (true) {
|
||||
const run = await api(`/api/runs/${runId}`);
|
||||
state.activeRuns.set(profileId, run);
|
||||
renderRunProgress(profileId);
|
||||
|
||||
if (doneStatus.has(run.status)) {
|
||||
return run;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, 700);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function restoreButtons(profile, backups) {
|
||||
if (!backups.length) {
|
||||
return '<p class="empty-inline">Nenhum backup executado ainda.</p>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="backup-history">
|
||||
${backups.map((backup) => `
|
||||
<button
|
||||
class="backup-item"
|
||||
data-action="restore"
|
||||
data-profile-id="${escapeHtml(profile.id)}"
|
||||
data-backup-id="${escapeHtml(backup.id)}"
|
||||
>
|
||||
<span>
|
||||
<strong>${escapeHtml(new Date(backup.createdAt).toLocaleString('pt-BR'))}</strong>
|
||||
<small>${escapeHtml(backup.mode)} · ${escapeHtml(getProfileScopeLabel(backup.backupScope))} · ${escapeHtml(backup.status)} · ${escapeHtml(backup.containers.map((item) => item.containerName).join(', '))}</small>
|
||||
${formatBackupFailures(backup)}
|
||||
</span>
|
||||
<em>Restore</em>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderProfiles() {
|
||||
elements.profileCount.textContent = String(state.profiles.length);
|
||||
|
||||
if (!state.profiles.length) {
|
||||
elements.profilesList.innerHTML = '<p class="empty-state">Nenhum profile salvo.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const backupsByProfile = await Promise.all(
|
||||
state.profiles.map(async (profile) => [profile.id, await api(`/api/profiles/${profile.id}/backups`)])
|
||||
);
|
||||
const backupMap = new Map(backupsByProfile);
|
||||
|
||||
elements.profilesList.innerHTML = state.profiles.map((profile) => `
|
||||
<article class="profile-card">
|
||||
<div class="profile-card-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(profile.name)}</h3>
|
||||
<p>${escapeHtml(String(profile.containerIds.length))} container(es) · ${escapeHtml(getProfileScopeLabel(profile.backupScope))}</p>
|
||||
<code>${escapeHtml(profile.backupDir)}</code>
|
||||
</div>
|
||||
${backupButtons(profile)}
|
||||
</div>
|
||||
<div class="chips">
|
||||
${profile.containerIds.map((containerId) => {
|
||||
const container = state.containers.find((item) => item.id === containerId);
|
||||
return `<span class="chip">${escapeHtml(container ? container.name : containerId.slice(0, 12))}</span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div class="run-progress hidden" data-run-progress="${escapeHtml(profile.id)}"></div>
|
||||
<div class="restore-block">
|
||||
<h4>Restaurar</h4>
|
||||
${restoreButtons(profile, backupMap.get(profile.id) || [])}
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
|
||||
renderAllRunProgress();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
elements.profileForm.reset();
|
||||
elements.profileId.value = '';
|
||||
elements.formModeBadge.textContent = 'criar';
|
||||
renderContainers();
|
||||
}
|
||||
|
||||
function fillForm(profile) {
|
||||
elements.profileId.value = profile.id;
|
||||
elements.profileName.value = profile.name;
|
||||
elements.backupDir.value = profile.backupDir;
|
||||
const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
|
||||
document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
|
||||
elements.formModeBadge.textContent = 'editar';
|
||||
renderContainers();
|
||||
for (const containerId of profile.containerIds) {
|
||||
const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`);
|
||||
if (input) {
|
||||
input.checked = true;
|
||||
}
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function loadContainers() {
|
||||
state.containers = await api('/api/containers');
|
||||
renderContainers();
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
state.profiles = await api('/api/profiles');
|
||||
await renderProfiles();
|
||||
}
|
||||
|
||||
async function saveProfile(event) {
|
||||
event.preventDefault();
|
||||
const payload = {
|
||||
id: elements.profileId.value || undefined,
|
||||
name: elements.profileName.value,
|
||||
backupDir: elements.backupDir.value,
|
||||
containerIds: getSelectedContainerIds(),
|
||||
backupScope: document.querySelector('input[name="backupScope"]:checked').value,
|
||||
};
|
||||
|
||||
try {
|
||||
await api('/api/profiles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadProfiles();
|
||||
resetForm();
|
||||
showToast('Profile salvo.');
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileAction(event) {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { action, profileId, backupId } = button.dataset;
|
||||
const profile = state.profiles.find((item) => item.id === profileId);
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'edit') {
|
||||
fillForm(profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!window.confirm(`Excluir o profile ${profile.name}?`)) {
|
||||
return;
|
||||
}
|
||||
await api(`/api/profiles/${profileId}`, { method: 'DELETE' });
|
||||
await loadProfiles();
|
||||
showToast('Profile removido.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'run') {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Executando...';
|
||||
const mode = getRunMode(profileId);
|
||||
|
||||
const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode }) });
|
||||
state.activeRuns.set(profileId, {
|
||||
id: start.runId,
|
||||
profileId,
|
||||
status: 'running',
|
||||
progress: {
|
||||
overall: { total: profile.containerIds.length, completed: 0, pending: profile.containerIds.length, percent: 0 },
|
||||
currentContainer: null,
|
||||
},
|
||||
});
|
||||
renderRunProgress(profileId);
|
||||
|
||||
const run = await pollRun(profileId, start.runId);
|
||||
state.activeRuns.delete(profileId);
|
||||
await loadProfiles();
|
||||
if (run.status === 'error') {
|
||||
showToast(run.error || 'Falha durante a execucao do backup.', true);
|
||||
} else {
|
||||
const backupStatus = run.result?.status;
|
||||
const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
|
||||
if (failures.length) {
|
||||
const details = failures
|
||||
.map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
|
||||
.join(' | ');
|
||||
showToast(`Backup com falhas. ${details}`, true);
|
||||
} else {
|
||||
showToast(
|
||||
backupStatus === 'ok' ? 'Backup concluido.' : 'Backup concluido com falhas parciais.',
|
||||
backupStatus !== 'ok',
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'restore') {
|
||||
if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backups = await api(`/api/profiles/${profileId}/backups`);
|
||||
const selectedBackup = backups.find((item) => item.id === backupId);
|
||||
if (!selectedBackup) {
|
||||
throw new Error('Backup selecionado nao encontrado.');
|
||||
}
|
||||
|
||||
const selectedContainerIds = await askRestoreContainerSelection(profile, selectedBackup);
|
||||
if (!selectedContainerIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Restaurando...';
|
||||
|
||||
const start = await api(`/api/profiles/${profileId}/restore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ backupId, containerIds: selectedContainerIds }),
|
||||
});
|
||||
state.activeRuns.set(profileId, {
|
||||
id: start.runId,
|
||||
kind: 'restore',
|
||||
profileId,
|
||||
status: 'running',
|
||||
progress: {
|
||||
operation: 'restore',
|
||||
overall: {
|
||||
total: selectedContainerIds.length,
|
||||
completed: 0,
|
||||
pending: selectedContainerIds.length,
|
||||
percent: 0,
|
||||
},
|
||||
currentContainer: null,
|
||||
},
|
||||
});
|
||||
renderRunProgress(profileId);
|
||||
|
||||
const run = await pollRun(profileId, start.runId);
|
||||
state.activeRuns.delete(profileId);
|
||||
await loadProfiles();
|
||||
|
||||
if (run.status === 'error') {
|
||||
showToast(run.error || 'Falha durante a execucao do restore.', true);
|
||||
} else {
|
||||
const restoreStatus = run.result?.status;
|
||||
const restoreStatsLines = (run.result?.containers || [])
|
||||
.filter((item) => item.status === 'ok' && item.stats)
|
||||
.map((item) => `${item.containerName}: apagados ${item.stats.deleted}, criados ${item.stats.created}, modificados ${item.stats.modified}`);
|
||||
|
||||
const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
|
||||
if (failures.length) {
|
||||
const details = failures
|
||||
.map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
|
||||
.join(' | ');
|
||||
const statsSummary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
|
||||
showToast(`Restore com falhas. ${details}.${statsSummary}`, true);
|
||||
} else {
|
||||
const summary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
|
||||
showToast(
|
||||
`${restoreStatus === 'ok' ? 'Restore concluido.' : 'Restore concluido com falhas parciais.'}${summary}`,
|
||||
restoreStatus !== 'ok',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await Promise.all([loadContainers(), loadProfiles()]);
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
elements.profileForm.addEventListener('submit', saveProfile);
|
||||
document.querySelector('#refreshContainers').addEventListener('click', init);
|
||||
document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles);
|
||||
document.querySelector('#clearForm').addEventListener('click', resetForm);
|
||||
elements.profilesList.addEventListener('click', handleProfileAction);
|
||||
|
||||
init();
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Docker Backup Profiles</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Docker volume backup</p>
|
||||
<h1>Profiles de backup com parada controlada por container.</h1>
|
||||
<p class="hero-copy">
|
||||
O app usa a Docker API para parar cada container, gerar backup compactado dos mounts e religar apenas quem estava rodando antes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<div>
|
||||
<span class="metric-label">Containers detectados</span>
|
||||
<strong id="containerCount">0</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="metric-label">Profiles salvos</span>
|
||||
<strong id="profileCount">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel form-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Novo profile</h2>
|
||||
<span id="formModeBadge" class="badge">criar</span>
|
||||
</div>
|
||||
|
||||
<form id="profileForm">
|
||||
<input type="hidden" id="profileId" />
|
||||
|
||||
<label>
|
||||
<span>Nome do profile</span>
|
||||
<input id="profileName" name="name" type="text" placeholder="Backup banco principal" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Diretorio de backup no host Docker</span>
|
||||
<input id="backupDir" name="backupDir" type="text" placeholder="/srv/docker-backups" required />
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Escopo do backup</legend>
|
||||
<div class="radio-grid">
|
||||
<label class="radio-card">
|
||||
<input type="radio" name="backupScope" value="volumes" checked />
|
||||
<span>Somente volumes</span>
|
||||
<small>mantem o comportamento atual de backup de volumes e binds</small>
|
||||
</label>
|
||||
<label class="radio-card">
|
||||
<input type="radio" name="backupScope" value="container" />
|
||||
<span>Container inteiro</span>
|
||||
<small>gera um unico tar por container a partir de /</small>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="container-picker">
|
||||
<div class="picker-header">
|
||||
<h3>Containers</h3>
|
||||
<button id="refreshContainers" type="button" class="secondary-button">Atualizar lista</button>
|
||||
</div>
|
||||
<div id="containerOptions" class="container-options"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="primary-button" type="submit">Salvar profile</button>
|
||||
<button class="secondary-button" type="button" id="clearForm">Limpar</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel list-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Profiles salvos</h2>
|
||||
<button id="reloadProfiles" type="button" class="secondary-button">Recarregar</button>
|
||||
</div>
|
||||
<div id="profilesList" class="profiles-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside id="toast" class="toast hidden"></aside>
|
||||
<div id="restoreModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-backdrop" data-action="close-restore-modal"></div>
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="restoreModalTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="restoreModalTitle">Selecionar containers para restore</h3>
|
||||
<button id="restoreModalClose" class="ghost-button small" type="button">Fechar</button>
|
||||
</div>
|
||||
<p id="restoreModalSubtitle" class="modal-subtitle"></p>
|
||||
<div id="restoreContainerOptions" class="modal-options"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="restoreModalSelectAll" class="secondary-button" type="button">Marcar todos</button>
|
||||
<button id="restoreModalConfirm" class="primary-button" type="button">Restaurar selecionados</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,548 @@
|
|||
:root {
|
||||
--bg: #f3efe6;
|
||||
--bg-soft: #fffaf2;
|
||||
--surface: rgba(255, 252, 245, 0.78);
|
||||
--line: rgba(69, 55, 37, 0.14);
|
||||
--text: #2e2418;
|
||||
--muted: #7b6c58;
|
||||
--accent: #156669;
|
||||
--accent-strong: #0b4d50;
|
||||
--danger: #a03838;
|
||||
--shadow: 0 20px 60px rgba(63, 43, 17, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(21, 102, 105, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 85% 20%, rgba(206, 131, 76, 0.18), transparent 22%),
|
||||
linear-gradient(180deg, #fbf7ef 0%, #efe6d5 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 64px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 0.8fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.2rem, 4vw, 4.4rem);
|
||||
line-height: 0.96;
|
||||
max-width: 10ch;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 64ch;
|
||||
color: var(--muted);
|
||||
font-size: 1.02rem;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel,
|
||||
.toast {
|
||||
backdrop-filter: blur(14px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-card strong {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 420px) 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 28px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.picker-header,
|
||||
.profile-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h2,
|
||||
.picker-header h3,
|
||||
.profile-card h3,
|
||||
.restore-block h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.chip,
|
||||
.state,
|
||||
.profile-card code {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.chip {
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(21, 102, 105, 0.1);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
form,
|
||||
.restore-block,
|
||||
.profiles-list {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
label,
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label span,
|
||||
legend {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.radio-card,
|
||||
.container-option,
|
||||
.backup-item {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.radio-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.radio-card input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.radio-card small,
|
||||
.container-option small,
|
||||
.profile-card p,
|
||||
.empty-state,
|
||||
.empty-inline {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.container-options,
|
||||
.chips,
|
||||
.backup-history {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.container-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.state {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.state.running {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.state.exited,
|
||||
.state.created {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button,
|
||||
.backup-item {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, opacity 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: rgba(21, 102, 105, 0.12);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(160, 56, 56, 0.08);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.primary-button:hover,
|
||||
.secondary-button:hover,
|
||||
.ghost-button:hover,
|
||||
.backup-item:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled,
|
||||
.ghost-button:disabled,
|
||||
.backup-item:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.form-actions,
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.run-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.mode-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.mode-picker span {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mode-select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-card p,
|
||||
.profile-card code {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.profile-card code {
|
||||
display: inline-block;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-item em {
|
||||
align-self: center;
|
||||
color: var(--accent-strong);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.backup-error {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.run-progress {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.progress-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(240, 252, 252, 0.72);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-header,
|
||||
.progress-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-header small,
|
||||
.progress-label-row,
|
||||
.current-file {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.progress-block {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(21, 102, 105, 0.16);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #17a2a8 0%, #156669 100%);
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
|
||||
.current-file {
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-log {
|
||||
max-height: 140px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.progress-log small {
|
||||
color: var(--muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
max-width: 360px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: rgba(160, 56, 56, 0.24);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 16, 10, 0.45);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
position: relative;
|
||||
width: min(640px, calc(100vw - 24px));
|
||||
max-height: min(80vh, 620px);
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-soft);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-header h3,
|
||||
.modal-subtitle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
}
|
||||
|
||||
.modal-option small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero,
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-shell {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.hero-card {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container-option,
|
||||
.profile-card-top,
|
||||
.backup-item {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.run-actions,
|
||||
.mode-picker {
|
||||
justify-items: stretch;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,850 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs/promises');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `"'"'`)}'`;
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item';
|
||||
}
|
||||
|
||||
function formatStamp(date = new Date()) {
|
||||
return date.toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
function normalizeDockerHostPath(inputPath) {
|
||||
const value = String(inputPath || '').trim();
|
||||
if (/^[A-Za-z]:[\\/]/.test(value)) {
|
||||
const drive = value[0].toLowerCase();
|
||||
const suffix = value.slice(2).replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
return `/run/desktop/mnt/host/${drive}/${suffix}`;
|
||||
}
|
||||
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function normalizeContainerPath(inputPath) {
|
||||
const value = String(inputPath || '').trim().replace(/\\/g, '/');
|
||||
if (!value.startsWith('/')) {
|
||||
throw new Error('Em execucao via Docker, o diretorio de backup deve ser absoluto dentro do container (ex.: /app/data/backups).');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMounts(containerInspect) {
|
||||
return (containerInspect.Mounts || [])
|
||||
.filter((mount) => mount.Type === 'bind' || mount.Type === 'volume')
|
||||
.map((mount) => ({
|
||||
type: mount.Type,
|
||||
name: mount.Name || null,
|
||||
source: mount.Source,
|
||||
destination: mount.Destination,
|
||||
rw: mount.RW,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const leftKey = `${left.destination}|${left.type}|${left.name || left.source}`;
|
||||
const rightKey = `${right.destination}|${right.type}|${right.name || right.source}`;
|
||||
return leftKey.localeCompare(rightKey);
|
||||
});
|
||||
}
|
||||
|
||||
function sameMountSignature(leftMounts, rightMounts) {
|
||||
if (leftMounts.length !== rightMounts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return leftMounts.every((left, index) => {
|
||||
const right = rightMounts[index];
|
||||
return (
|
||||
left.type === right.type
|
||||
&& left.name === right.name
|
||||
&& left.source === right.source
|
||||
&& left.destination === right.destination
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getMountBindingSource(mount) {
|
||||
return mount.type === 'volume' ? mount.name : mount.source;
|
||||
}
|
||||
|
||||
function normalizeBackupScope(value) {
|
||||
return value === 'container' ? 'container' : 'volumes';
|
||||
}
|
||||
|
||||
function containerSnapshotPath(profileId, containerId, scope) {
|
||||
return `/tmp/dockerbackup-${slugify(profileId)}-${containerId.slice(0, 12)}-${scope}.snar`;
|
||||
}
|
||||
|
||||
function toContainerRelPath(absPath) {
|
||||
return String(absPath || '').replace(/^\/+/, '') || '.';
|
||||
}
|
||||
|
||||
function parseManifestLines(rawOutput) {
|
||||
const map = new Map();
|
||||
for (const line of String(rawOutput || '').split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [relativePath, sizeRaw, mtimeRaw, scopeRaw] = trimmed.split('|');
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = scopeRaw
|
||||
? `${toContainerRelPath(scopeRaw)}/${relativePath}`.replace(/\/\//g, '/')
|
||||
: relativePath;
|
||||
|
||||
map.set(key, {
|
||||
size: Number(sizeRaw || 0),
|
||||
mtime: Number(mtimeRaw || 0),
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function calculateManifestDiff(beforeMap, afterMap) {
|
||||
let deleted = 0;
|
||||
let created = 0;
|
||||
let modified = 0;
|
||||
|
||||
for (const [filePath, beforeEntry] of beforeMap.entries()) {
|
||||
const afterEntry = afterMap.get(filePath);
|
||||
if (!afterEntry) {
|
||||
deleted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (beforeEntry.size !== afterEntry.size || beforeEntry.mtime !== afterEntry.mtime) {
|
||||
modified += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of afterMap.keys()) {
|
||||
if (!beforeMap.has(filePath)) {
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted, created, modified };
|
||||
}
|
||||
|
||||
class BackupService {
|
||||
constructor({ dockerService, store }) {
|
||||
this.dockerService = dockerService;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async runProfile(profileId, options = {}) {
|
||||
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
||||
const profile = await this.store.getProfile(profileId);
|
||||
if (!profile) {
|
||||
throw new Error('Profile nao encontrado.');
|
||||
}
|
||||
|
||||
if (!profile.containerIds.length) {
|
||||
throw new Error('Selecione ao menos um container no profile.');
|
||||
}
|
||||
|
||||
const effectiveMode = ['full', 'incremental'].includes(options.mode)
|
||||
? options.mode
|
||||
: (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full');
|
||||
|
||||
const backupScope = normalizeBackupScope(profile.backupScope);
|
||||
|
||||
const backupRun = {
|
||||
id: randomUUID(),
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
mode: effectiveMode,
|
||||
backupScope,
|
||||
backupDir: profile.backupDir,
|
||||
status: 'ok',
|
||||
containers: [],
|
||||
};
|
||||
|
||||
const progress = {
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
startedAt: backupRun.createdAt,
|
||||
status: 'running',
|
||||
overall: {
|
||||
total: profile.containerIds.length,
|
||||
completed: 0,
|
||||
pending: profile.containerIds.length,
|
||||
percent: 0,
|
||||
},
|
||||
currentContainer: null,
|
||||
};
|
||||
|
||||
const emitProgress = () => {
|
||||
onProgress(JSON.parse(JSON.stringify(progress)));
|
||||
};
|
||||
|
||||
emitProgress();
|
||||
|
||||
for (const [index, containerId] of profile.containerIds.entries()) {
|
||||
progress.currentContainer = {
|
||||
containerId,
|
||||
index: index + 1,
|
||||
total: profile.containerIds.length,
|
||||
containerName: null,
|
||||
status: 'running',
|
||||
step: 'iniciando',
|
||||
message: 'Preparando backup do container.',
|
||||
logs: [],
|
||||
percent: 0,
|
||||
file: {
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentFile: null,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
emitProgress();
|
||||
|
||||
const containerBackup = await this.backupContainer(profile, containerId, backupRun.createdAt, {
|
||||
mode: effectiveMode,
|
||||
backupScope,
|
||||
onProgress: (containerProgress) => {
|
||||
progress.currentContainer = {
|
||||
...progress.currentContainer,
|
||||
...containerProgress,
|
||||
};
|
||||
emitProgress();
|
||||
},
|
||||
});
|
||||
|
||||
backupRun.containers.push(containerBackup);
|
||||
if (containerBackup.status !== 'ok') {
|
||||
backupRun.status = 'partial';
|
||||
}
|
||||
|
||||
progress.overall.completed += 1;
|
||||
progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed);
|
||||
progress.overall.percent = progress.overall.total
|
||||
? Math.round((progress.overall.completed / progress.overall.total) * 100)
|
||||
: 100;
|
||||
progress.currentContainer = {
|
||||
...(progress.currentContainer || {}),
|
||||
status: containerBackup.status,
|
||||
percent: 100,
|
||||
};
|
||||
emitProgress();
|
||||
}
|
||||
|
||||
await this.store.addBackup(backupRun);
|
||||
|
||||
progress.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors';
|
||||
progress.finishedAt = new Date().toISOString();
|
||||
progress.currentContainer = null;
|
||||
progress.overall.percent = 100;
|
||||
progress.overall.pending = 0;
|
||||
emitProgress();
|
||||
|
||||
return backupRun;
|
||||
}
|
||||
|
||||
async backupContainer(profile, containerId, runDateIso, options = {}) {
|
||||
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
||||
const runMode = ['full', 'incremental'].includes(options.mode)
|
||||
? options.mode
|
||||
: (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full');
|
||||
const backupScope = normalizeBackupScope(options.backupScope || profile.backupScope);
|
||||
|
||||
let inspect;
|
||||
let mounts = [];
|
||||
let containerName = containerId.slice(0, 12);
|
||||
|
||||
try {
|
||||
inspect = await this.dockerService.inspectContainer(containerId);
|
||||
mounts = normalizeMounts(inspect);
|
||||
containerName = inspect.Name.replace(/^\//, '');
|
||||
} catch (error) {
|
||||
onProgress({ containerName, status: 'error', percent: 100, step: 'erro', message: error.message });
|
||||
return {
|
||||
containerId,
|
||||
containerName,
|
||||
status: 'error',
|
||||
mode: runMode,
|
||||
error: `Falha ao inspecionar container: ${error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (backupScope === 'volumes' && !mounts.length) {
|
||||
onProgress({
|
||||
containerName,
|
||||
status: 'skipped',
|
||||
step: 'concluido',
|
||||
message: 'Container sem volumes elegiveis.',
|
||||
percent: 100,
|
||||
file: { current: 0, total: 0, currentFile: null, percent: 100 },
|
||||
});
|
||||
return {
|
||||
containerId: inspect.Id,
|
||||
containerName,
|
||||
status: 'skipped',
|
||||
mode: runMode,
|
||||
message: 'Container sem volumes ou bind mounts elegiveis.',
|
||||
};
|
||||
}
|
||||
|
||||
const runInDocker = this.dockerService.isRunningInDocker();
|
||||
const backupRoot = runInDocker
|
||||
? normalizeContainerPath(profile.backupDir)
|
||||
: normalizeDockerHostPath(profile.backupDir);
|
||||
const safeContainerName = slugify(containerName);
|
||||
const safeProfileName = slugify(profile.name);
|
||||
const stamp = formatStamp(new Date(runDateIso));
|
||||
const archiveRelativePath = path.posix.join(safeProfileName, safeContainerName, `${stamp}-${runMode}.tar.gz`);
|
||||
const snapshotRelativePath = path.posix.join(safeProfileName, safeContainerName, 'latest.snar');
|
||||
|
||||
const containerBackup = {
|
||||
containerId: inspect.Id,
|
||||
containerName,
|
||||
backupScope,
|
||||
backupPaths: backupScope === 'container' ? ['/'] : mounts.map((mount) => mount.destination),
|
||||
mountSignature: mounts,
|
||||
archiveRelativePath,
|
||||
snapshotRelativePath,
|
||||
wasRunning: inspect.State?.Running === true,
|
||||
mode: runMode,
|
||||
status: 'ok',
|
||||
};
|
||||
|
||||
const logs = [];
|
||||
const pushLog = (message, step = 'processando') => {
|
||||
const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`;
|
||||
logs.push(line);
|
||||
while (logs.length > 40) {
|
||||
logs.shift();
|
||||
}
|
||||
|
||||
onProgress({
|
||||
containerName,
|
||||
step,
|
||||
message,
|
||||
logs: [...logs],
|
||||
});
|
||||
};
|
||||
|
||||
let fileTotal = 0;
|
||||
let fileCurrent = 0;
|
||||
const updateFileProgress = (currentFile = null) => {
|
||||
const filePercent = fileTotal > 0 ? Math.min(100, Math.round((fileCurrent / fileTotal) * 100)) : 0;
|
||||
onProgress({
|
||||
containerName,
|
||||
status: 'running',
|
||||
step: 'processando',
|
||||
percent: filePercent,
|
||||
file: {
|
||||
current: fileCurrent,
|
||||
total: fileTotal,
|
||||
currentFile,
|
||||
percent: filePercent,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (backupScope === 'container' && !runInDocker) {
|
||||
throw new Error('Backup do container inteiro requer app executando via Docker.');
|
||||
}
|
||||
|
||||
pushLog(`Escopo selecionado: ${backupScope === 'container' ? 'container inteiro' : 'somente volumes'}.`, 'preparando');
|
||||
|
||||
if (runInDocker) {
|
||||
await this.dockerService.ensureLocalDirectory(backupRoot);
|
||||
pushLog(`Diretorio de backup pronto em ${backupRoot}.`, 'preparando');
|
||||
|
||||
const originalRunning = inspect.State?.Running === true;
|
||||
let tempStarted = false;
|
||||
try {
|
||||
if (originalRunning) {
|
||||
pushLog('Container ativo detectado. Parando antes do backup.', 'preparando');
|
||||
await this.dockerService.stopContainer(containerId);
|
||||
}
|
||||
|
||||
pushLog('Iniciando container temporariamente para snapshot.', 'preparando');
|
||||
await this.dockerService.startContainer(containerId);
|
||||
tempStarted = true;
|
||||
|
||||
const sourcePaths = backupScope === 'container'
|
||||
? ['/']
|
||||
: mounts.map((mount) => mount.destination);
|
||||
const relSourcePaths = sourcePaths.map((item) => toContainerRelPath(item));
|
||||
|
||||
if (backupScope === 'volumes') {
|
||||
pushLog('Contando arquivos para barra de progresso.', 'contando');
|
||||
const countCmd = `set -eu; TOTAL=0; for p in ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}; do if [ -e \"/$p\" ]; then C=$(find \"/$p\" -type f 2>/dev/null | wc -l | tr -d \" \" ); TOTAL=$((TOTAL + C)); fi; done; echo \"$TOTAL\"`;
|
||||
const output = await this.dockerService.runContainerCommand(containerId, countCmd);
|
||||
const parsed = Number(output.split(/\r?\n/).pop());
|
||||
fileTotal = Number.isFinite(parsed) ? parsed : 0;
|
||||
pushLog(`Total de arquivos identificado: ${fileTotal}.`, 'contando');
|
||||
}
|
||||
|
||||
const absoluteArchivePath = path.posix.join(backupRoot, archiveRelativePath);
|
||||
|
||||
let newerMtimeFlag = '';
|
||||
if (runMode === 'incremental') {
|
||||
const lastTime = await this.store.getLastContainerBackupTime(profile.id, containerId);
|
||||
if (lastTime) {
|
||||
const unixSec = Math.floor(new Date(lastTime).getTime() / 1000);
|
||||
newerMtimeFlag = `--newer-mtime=@${unixSec}`;
|
||||
pushLog(`Backup incremental: incluindo arquivos modificados apos ${lastTime}.`, 'preparando');
|
||||
} else {
|
||||
pushLog('Aviso: nenhum backup anterior encontrado, gerando full.', 'preparando');
|
||||
}
|
||||
}
|
||||
|
||||
const tarParts = [
|
||||
'set -eu',
|
||||
'umask 077',
|
||||
'echo "__DBKP_TAR_BEGIN__" 1>&2',
|
||||
];
|
||||
|
||||
if (backupScope === 'container') {
|
||||
tarParts.push(
|
||||
`tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / --exclude=proc --exclude=sys --exclude=dev --exclude=run --exclude=tmp .`
|
||||
);
|
||||
} else {
|
||||
tarParts.push(
|
||||
`tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}`
|
||||
);
|
||||
}
|
||||
|
||||
updateFileProgress();
|
||||
pushLog('Iniciando compactacao tar do container.', 'gerando-tar');
|
||||
|
||||
await this.dockerService.streamContainerCommandToFile(containerId, tarParts.join(' && '), absoluteArchivePath, {
|
||||
onOutput: (line, stream) => {
|
||||
const normalizedLine = String(line || '').trim();
|
||||
if (!normalizedLine || stream !== 'stderr' || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedLine.startsWith('tar:')) {
|
||||
fileCurrent += 1;
|
||||
updateFileProgress(normalizedLine);
|
||||
} else {
|
||||
pushLog(`Aviso do tar: ${normalizedLine}`, 'gerando-tar');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pushLog(`Arquivo gerado: ${absoluteArchivePath}`, 'finalizando');
|
||||
} finally {
|
||||
if (tempStarted) {
|
||||
pushLog('Encerrando container apos backup.', 'finalizando');
|
||||
await this.dockerService.stopContainer(containerId).catch(() => null);
|
||||
}
|
||||
|
||||
if (originalRunning) {
|
||||
pushLog('Reiniciando container (estava ativo antes do backup).', 'finalizando');
|
||||
await this.dockerService.startContainer(containerId).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress({
|
||||
containerName,
|
||||
status: 'ok',
|
||||
step: 'concluido',
|
||||
message: 'Backup concluido com sucesso.',
|
||||
percent: 100,
|
||||
file: {
|
||||
current: Math.max(fileCurrent, fileTotal),
|
||||
total: fileTotal,
|
||||
currentFile: null,
|
||||
percent: 100,
|
||||
},
|
||||
});
|
||||
|
||||
return containerBackup;
|
||||
}
|
||||
|
||||
if (backupScope === 'container') {
|
||||
throw new Error('Backup de container inteiro sem Docker nativo nao e suportado.');
|
||||
}
|
||||
|
||||
await this.dockerService.ensureHostDirectory(backupRoot);
|
||||
const wasRunning = inspect.State?.Running === true;
|
||||
if (wasRunning) {
|
||||
await this.dockerService.stopContainer(containerId);
|
||||
}
|
||||
|
||||
const binds = [`${backupRoot}:/backuproot`];
|
||||
for (const [index, mount] of mounts.entries()) {
|
||||
binds.push(`${getMountBindingSource(mount)}:/payload/m${index}:ro`);
|
||||
}
|
||||
|
||||
const archivePath = `/backuproot/${archiveRelativePath}`;
|
||||
const snapshotPath = `/backuproot/${snapshotRelativePath}`;
|
||||
const parentDir = path.posix.dirname(archivePath);
|
||||
const cmdParts = ['set -eu', `mkdir -p ${shellQuote(parentDir)}`];
|
||||
if (runMode === 'full') {
|
||||
cmdParts.push(`rm -f ${shellQuote(snapshotPath)}`);
|
||||
}
|
||||
cmdParts.push('TOTAL_FILES=$(find /payload -type f | wc -l | tr -d " ")');
|
||||
cmdParts.push('echo "__DBKP_TOTAL_FILES__=${TOTAL_FILES}"');
|
||||
cmdParts.push(`tar --listed-incremental=${shellQuote(snapshotPath)} -czvf ${shellQuote(archivePath)} -C /payload .`);
|
||||
|
||||
await this.dockerService.runHelper({
|
||||
binds,
|
||||
cmd: cmdParts.join(' && '),
|
||||
onOutput: (line, stream) => {
|
||||
const normalizedLine = String(line || '').trim();
|
||||
if (!normalizedLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedLine.startsWith('__DBKP_TOTAL_FILES__=')) {
|
||||
const parsed = Number(normalizedLine.split('=')[1]);
|
||||
fileTotal = Number.isFinite(parsed) ? parsed : 0;
|
||||
updateFileProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream === 'stdout' && !normalizedLine.startsWith('tar:')) {
|
||||
fileCurrent += 1;
|
||||
updateFileProgress(normalizedLine);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (wasRunning) {
|
||||
await this.dockerService.startContainer(containerId).catch(() => null);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
containerName,
|
||||
status: 'ok',
|
||||
step: 'concluido',
|
||||
message: 'Backup concluido com sucesso.',
|
||||
percent: 100,
|
||||
file: {
|
||||
current: Math.max(fileCurrent, fileTotal),
|
||||
total: fileTotal,
|
||||
currentFile: null,
|
||||
percent: 100,
|
||||
},
|
||||
});
|
||||
|
||||
return containerBackup;
|
||||
} catch (error) {
|
||||
onProgress({
|
||||
containerName,
|
||||
status: 'error',
|
||||
step: 'erro',
|
||||
message: error.message,
|
||||
percent: 100,
|
||||
});
|
||||
return {
|
||||
...containerBackup,
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackup(profileId, backupId, options = {}) {
|
||||
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
||||
const selectedContainerIds = options.selectedContainerIds;
|
||||
|
||||
const profile = await this.store.getProfile(profileId);
|
||||
if (!profile) {
|
||||
throw new Error('Profile nao encontrado.');
|
||||
}
|
||||
|
||||
const backupRun = await this.store.getBackup(backupId);
|
||||
if (!backupRun || backupRun.profileId !== profileId) {
|
||||
throw new Error('Backup nao encontrado para este profile.');
|
||||
}
|
||||
|
||||
const allowedContainerIds = new Set(Array.isArray(selectedContainerIds) && selectedContainerIds.length
|
||||
? selectedContainerIds
|
||||
: backupRun.containers.map((item) => item.containerId));
|
||||
|
||||
const targets = backupRun.containers.filter((item) => item.status === 'ok' && allowedContainerIds.has(item.containerId));
|
||||
if (!targets.length) {
|
||||
throw new Error('Nenhum container valido foi selecionado para restore.');
|
||||
}
|
||||
|
||||
const progress = {
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
operation: 'restore',
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
overall: {
|
||||
total: targets.length,
|
||||
completed: 0,
|
||||
pending: targets.length,
|
||||
percent: 0,
|
||||
},
|
||||
currentContainer: null,
|
||||
};
|
||||
|
||||
const emitProgress = () => {
|
||||
onProgress(JSON.parse(JSON.stringify(progress)));
|
||||
};
|
||||
|
||||
emitProgress();
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const containerEntry of targets) {
|
||||
const logs = [];
|
||||
const pushLog = (message, step = 'restaurando') => {
|
||||
const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`;
|
||||
logs.push(line);
|
||||
while (logs.length > 40) {
|
||||
logs.shift();
|
||||
}
|
||||
|
||||
progress.currentContainer = {
|
||||
...(progress.currentContainer || {}),
|
||||
message,
|
||||
step,
|
||||
logs: [...logs],
|
||||
};
|
||||
emitProgress();
|
||||
};
|
||||
|
||||
progress.currentContainer = {
|
||||
containerId: containerEntry.containerId,
|
||||
containerName: containerEntry.containerName,
|
||||
status: 'running',
|
||||
step: 'preparando',
|
||||
message: 'Preparando restauracao do container.',
|
||||
logs: [],
|
||||
percent: 0,
|
||||
file: {
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentFile: null,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
emitProgress();
|
||||
|
||||
try {
|
||||
const chain = await this.store.getBackupsForContainer(profileId, containerEntry.containerId, backupId);
|
||||
if (!chain.length || chain[0].mode !== 'full') {
|
||||
throw new Error(`Nao existe cadeia full + incremental valida para ${containerEntry.containerName}.`);
|
||||
}
|
||||
|
||||
pushLog(`Cadeia de restore encontrada com ${chain.length} arquivo(s).`, 'preparando');
|
||||
|
||||
const restoreInfo = await this.restoreContainer(profile, containerEntry, chain, {
|
||||
onProgress: (snapshot) => {
|
||||
progress.currentContainer = {
|
||||
...progress.currentContainer,
|
||||
...snapshot,
|
||||
};
|
||||
emitProgress();
|
||||
},
|
||||
pushLog,
|
||||
});
|
||||
|
||||
progress.currentContainer = {
|
||||
...progress.currentContainer,
|
||||
status: 'ok',
|
||||
step: 'concluido',
|
||||
message: 'Restore concluido com sucesso.',
|
||||
percent: 100,
|
||||
file: {
|
||||
...(progress.currentContainer?.file || {}),
|
||||
percent: 100,
|
||||
},
|
||||
};
|
||||
emitProgress();
|
||||
|
||||
results.push({
|
||||
containerId: containerEntry.containerId,
|
||||
containerName: containerEntry.containerName,
|
||||
status: 'ok',
|
||||
stats: restoreInfo?.stats || null,
|
||||
});
|
||||
} catch (error) {
|
||||
pushLog(`Falha no restore: ${error.message}`, 'erro');
|
||||
progress.currentContainer = {
|
||||
...progress.currentContainer,
|
||||
status: 'error',
|
||||
step: 'erro',
|
||||
message: error.message,
|
||||
percent: 100,
|
||||
};
|
||||
emitProgress();
|
||||
|
||||
results.push({
|
||||
containerId: containerEntry.containerId,
|
||||
containerName: containerEntry.containerName,
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
progress.overall.completed += 1;
|
||||
progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed);
|
||||
progress.overall.percent = progress.overall.total
|
||||
? Math.round((progress.overall.completed / progress.overall.total) * 100)
|
||||
: 100;
|
||||
emitProgress();
|
||||
}
|
||||
|
||||
progress.status = results.every((item) => item.status === 'ok') ? 'completed' : 'completed-with-errors';
|
||||
progress.finishedAt = new Date().toISOString();
|
||||
progress.currentContainer = null;
|
||||
progress.overall.percent = 100;
|
||||
progress.overall.pending = 0;
|
||||
emitProgress();
|
||||
|
||||
return {
|
||||
backupId,
|
||||
status: results.every((item) => item.status === 'ok') ? 'ok' : 'partial',
|
||||
containers: results,
|
||||
};
|
||||
}
|
||||
|
||||
async restoreContainer(profile, targetEntry, chain, options = {}) {
|
||||
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
||||
const pushLog = typeof options.pushLog === 'function' ? options.pushLog : () => {};
|
||||
|
||||
const inspect = await this.dockerService.inspectContainer(targetEntry.containerId);
|
||||
const backupScope = normalizeBackupScope(targetEntry.backupScope || profile.backupScope);
|
||||
const currentMounts = normalizeMounts(inspect);
|
||||
|
||||
if (backupScope === 'volumes' && !sameMountSignature(targetEntry.mountSignature, currentMounts)) {
|
||||
throw new Error(`Os mounts atuais do container ${targetEntry.containerName} nao batem com o backup selecionado.`);
|
||||
}
|
||||
|
||||
const runInDocker = this.dockerService.isRunningInDocker();
|
||||
const useNativeRestore = runInDocker;
|
||||
|
||||
if (useNativeRestore) {
|
||||
const backupRoot = normalizeContainerPath(profile.backupDir);
|
||||
const originalWasRunning = inspect.State?.Running === true;
|
||||
const restoreStats = { deleted: 0, created: 0, modified: 0 };
|
||||
|
||||
try {
|
||||
if (originalWasRunning) {
|
||||
pushLog('Container ativo detectado. Parando antes do restore.', 'preparando');
|
||||
await this.dockerService.stopContainer(targetEntry.containerId);
|
||||
}
|
||||
|
||||
if (backupScope === 'volumes') {
|
||||
const restorePaths = (chain[0]?.backupPaths && chain[0].backupPaths.length)
|
||||
? chain[0].backupPaths
|
||||
: currentMounts.map((mount) => mount.destination);
|
||||
|
||||
// Validar que todos os archives existem antes de tocar nos dados.
|
||||
for (const entry of chain) {
|
||||
await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath));
|
||||
}
|
||||
|
||||
// Restaurar via Docker API (putArchive) — funciona com container parado.
|
||||
// O archive foi gerado com -C / incluindo os caminhos relativos dos volumes,
|
||||
// portanto o destino do putArchive e sempre /.
|
||||
for (const [index, entry] of chain.entries()) {
|
||||
const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath);
|
||||
|
||||
onProgress({
|
||||
step: 'restaurando',
|
||||
file: {
|
||||
current: index + 1,
|
||||
total: chain.length,
|
||||
currentFile: entry.archiveRelativePath,
|
||||
percent: Math.round(((index + 1) / chain.length) * 100),
|
||||
},
|
||||
percent: Math.round(((index + 1) / chain.length) * 100),
|
||||
});
|
||||
pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando');
|
||||
|
||||
await this.dockerService.putCompressedArchiveFromFile(
|
||||
targetEntry.containerId,
|
||||
'/',
|
||||
absoluteArchivePath,
|
||||
);
|
||||
}
|
||||
|
||||
pushLog('Restore de volumes concluido.', 'finalizando');
|
||||
} else {
|
||||
// Escopo container inteiro.
|
||||
for (const entry of chain) {
|
||||
await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath));
|
||||
}
|
||||
|
||||
for (const [index, entry] of chain.entries()) {
|
||||
const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath);
|
||||
|
||||
onProgress({
|
||||
step: 'restaurando',
|
||||
file: {
|
||||
current: index + 1,
|
||||
total: chain.length,
|
||||
currentFile: entry.archiveRelativePath,
|
||||
percent: Math.round(((index + 1) / chain.length) * 100),
|
||||
},
|
||||
percent: Math.round(((index + 1) / chain.length) * 100),
|
||||
});
|
||||
pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando');
|
||||
|
||||
await this.dockerService.putCompressedArchiveFromFile(targetEntry.containerId, '/', absoluteArchivePath);
|
||||
}
|
||||
|
||||
pushLog('Restore do container concluido.', 'finalizando');
|
||||
}
|
||||
} finally {
|
||||
if (originalWasRunning) {
|
||||
pushLog('Reiniciando container (estava ativo antes do restore).', 'finalizando');
|
||||
await this.dockerService.startContainer(targetEntry.containerId).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
return { stats: restoreStats };
|
||||
}
|
||||
|
||||
if (backupScope === 'container') {
|
||||
throw new Error('Restore do container inteiro requer app executando via Docker.');
|
||||
}
|
||||
|
||||
const backupRoot = normalizeDockerHostPath(profile.backupDir);
|
||||
const wasRunning = inspect.State?.Running === true;
|
||||
const binds = [`${backupRoot}:/backuproot:ro`];
|
||||
for (const [index, mount] of currentMounts.entries()) {
|
||||
binds.push(`${getMountBindingSource(mount)}:/restore/m${index}`);
|
||||
}
|
||||
|
||||
const cleanupCommands = currentMounts.map((_mount, index) => (
|
||||
`find ${shellQuote(`/restore/m${index}`)} -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`
|
||||
));
|
||||
|
||||
const restoreCommands = chain.map((entry) => (
|
||||
`tar --listed-incremental=/dev/null -xzf ${shellQuote(`/backuproot/${entry.archiveRelativePath}`)} -C /restore`
|
||||
));
|
||||
|
||||
try {
|
||||
if (wasRunning) {
|
||||
await this.dockerService.stopContainer(targetEntry.containerId);
|
||||
}
|
||||
|
||||
const cmd = ['set -eu', ...cleanupCommands, ...restoreCommands].join(' && ');
|
||||
await this.dockerService.runHelper({ binds, cmd });
|
||||
} finally {
|
||||
if (wasRunning) {
|
||||
await this.dockerService.startContainer(targetEntry.containerId).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
return { stats: null };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BackupService;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
const path = require('path');
|
||||
|
||||
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
|
||||
module.exports = {
|
||||
port: Number(process.env.PORT || 3000),
|
||||
dataDir,
|
||||
storePath: path.join(dataDir, 'store.json'),
|
||||
dockerSocketPath: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock',
|
||||
helperImage: process.env.HELPER_IMAGE || 'node:20-bookworm-slim',
|
||||
};
|
||||
|
|
@ -0,0 +1,486 @@
|
|||
const Docker = require('dockerode');
|
||||
const { PassThrough } = require('stream');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const { spawn } = require('child_process');
|
||||
const { pipeline } = require('stream/promises');
|
||||
|
||||
function detectRunningInContainer() {
|
||||
if (fs.existsSync('/.dockerenv')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
|
||||
return /(docker|containerd|kubepods|cri-o)/i.test(cgroup);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `"'"'`)}'`;
|
||||
}
|
||||
|
||||
class DockerService {
|
||||
constructor({ socketPath, helperImage }) {
|
||||
this.docker = new Docker({ socketPath });
|
||||
this.helperImage = helperImage;
|
||||
this.runningInContainer = detectRunningInContainer();
|
||||
}
|
||||
|
||||
isRunningInDocker() {
|
||||
return this.runningInContainer;
|
||||
}
|
||||
|
||||
async listContainers() {
|
||||
const containers = await this.docker.listContainers({ all: true });
|
||||
return containers
|
||||
.map((container) => ({
|
||||
id: container.Id,
|
||||
name: (container.Names[0] || '').replace(/^\//, ''),
|
||||
image: container.Image,
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
async inspectContainer(containerId) {
|
||||
return this.docker.getContainer(containerId).inspect();
|
||||
}
|
||||
|
||||
async stopContainer(containerId) {
|
||||
await this.docker.getContainer(containerId).stop();
|
||||
}
|
||||
|
||||
async startContainer(containerId) {
|
||||
await this.docker.getContainer(containerId).start();
|
||||
}
|
||||
|
||||
async ensureImage(imageName = this.helperImage) {
|
||||
try {
|
||||
await this.docker.getImage(imageName).inspect();
|
||||
return;
|
||||
} catch {
|
||||
const stream = await this.docker.pull(imageName);
|
||||
await new Promise((resolve, reject) => {
|
||||
this.docker.modem.followProgress(stream, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async ensureHostDirectory(hostPath) {
|
||||
const normalized = String(hostPath || '').trim().replace(/\\/g, '/');
|
||||
if (!normalized || !normalized.startsWith('/')) {
|
||||
throw new Error(`Diretorio de backup invalido: ${hostPath}`);
|
||||
}
|
||||
|
||||
if (this.runningInContainer) {
|
||||
throw new Error('App rodando em container nao deve criar helper para backup/restore.');
|
||||
}
|
||||
|
||||
await this.ensureImage();
|
||||
|
||||
const container = await this.docker.createContainer({
|
||||
Image: this.helperImage,
|
||||
Cmd: ['sh', '-lc', `mkdir -p ${shellQuote(`/hostfs${normalized}`)}`],
|
||||
Tty: false,
|
||||
HostConfig: {
|
||||
Binds: ['/:/hostfs'],
|
||||
AutoRemove: false,
|
||||
NetworkMode: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await container.start();
|
||||
const [result, logs] = await Promise.all([
|
||||
container.wait(),
|
||||
container.logs({ stdout: true, stderr: true, follow: false }),
|
||||
]);
|
||||
|
||||
if (result.StatusCode !== 0) {
|
||||
throw new Error(logs.toString('utf8').trim() || 'Falha ao criar diretorio de backup no host.');
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLocalDirectory(localPath) {
|
||||
const normalized = String(localPath || '').trim().replace(/\\/g, '/');
|
||||
if (!normalized || !normalized.startsWith('/')) {
|
||||
throw new Error(`Diretorio de backup invalido: ${localPath}`);
|
||||
}
|
||||
|
||||
await fsp.mkdir(normalized, { recursive: true });
|
||||
}
|
||||
|
||||
async exportContainerPathArchive(containerId, containerPath, targetFilePath) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const archiveStream = await container.getArchive({ path: containerPath });
|
||||
await fsp.mkdir(path.dirname(targetFilePath), { recursive: true });
|
||||
await pipeline(archiveStream, fs.createWriteStream(targetFilePath));
|
||||
}
|
||||
|
||||
async putArchiveFromFile(containerId, destinationPath, sourceFilePath) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const source = fs.createReadStream(sourceFilePath);
|
||||
await container.putArchive(source, { path: destinationPath });
|
||||
}
|
||||
|
||||
async putCompressedArchiveFromFile(containerId, destinationPath, sourceFilePath) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const source = fs.createReadStream(sourceFilePath).pipe(zlib.createGunzip());
|
||||
await container.putArchive(source, { path: destinationPath });
|
||||
}
|
||||
|
||||
async runContainerCommand(containerId, cmd) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const exec = await container.exec({
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: false,
|
||||
Cmd: ['sh', '-lc', cmd],
|
||||
});
|
||||
|
||||
const stream = await exec.start({ hijack: true, stdin: false });
|
||||
const stdoutStream = new PassThrough();
|
||||
const stderrStream = new PassThrough();
|
||||
this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
|
||||
let output = '';
|
||||
stdoutStream.on('data', (chunk) => {
|
||||
output += chunk.toString('utf8');
|
||||
});
|
||||
stderrStream.on('data', (chunk) => {
|
||||
output += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
let info = await exec.inspect();
|
||||
while (info.Running) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
info = await exec.inspect();
|
||||
}
|
||||
|
||||
if (typeof stream.destroy === 'function') {
|
||||
stream.destroy();
|
||||
}
|
||||
stdoutStream.end();
|
||||
stderrStream.end();
|
||||
|
||||
const trimmed = output.trim();
|
||||
if (info.ExitCode !== 0) {
|
||||
throw new Error(output.trim() || `Comando em container terminou com codigo ${info.ExitCode}`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async streamContainerCommandToFile(containerId, cmd, targetFilePath, options = {}) {
|
||||
const onOutput = typeof options.onOutput === 'function' ? options.onOutput : () => {};
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const exec = await container.exec({
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: false,
|
||||
Cmd: ['sh', '-lc', cmd],
|
||||
});
|
||||
|
||||
const stream = await exec.start({ hijack: true, stdin: false });
|
||||
const stdoutStream = new PassThrough();
|
||||
const stderrStream = new PassThrough();
|
||||
this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
|
||||
await fsp.mkdir(path.dirname(targetFilePath), { recursive: true });
|
||||
const writeStream = fs.createWriteStream(targetFilePath);
|
||||
stdoutStream.pipe(writeStream);
|
||||
|
||||
const stderrDone = new Promise((resolve) => {
|
||||
let buffer = '';
|
||||
|
||||
stderrStream.on('data', (chunk) => {
|
||||
const text = chunk.toString('utf8');
|
||||
buffer += text;
|
||||
const parts = buffer.split(/\r?\n/);
|
||||
buffer = parts.pop() || '';
|
||||
for (const line of parts) {
|
||||
onOutput(line, 'stderr');
|
||||
}
|
||||
});
|
||||
|
||||
stderrStream.on('end', () => {
|
||||
if (buffer) {
|
||||
onOutput(buffer, 'stderr');
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
let info = await exec.inspect();
|
||||
while (info.Running) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
info = await exec.inspect();
|
||||
}
|
||||
|
||||
if (typeof stream.destroy === 'function') {
|
||||
stream.destroy();
|
||||
}
|
||||
stdoutStream.end();
|
||||
stderrStream.end();
|
||||
|
||||
await Promise.all([
|
||||
stderrDone,
|
||||
new Promise((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
}),
|
||||
]);
|
||||
|
||||
if (info.ExitCode !== 0) {
|
||||
throw new Error(`Comando de stream em container terminou com codigo ${info.ExitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
async copyFileToContainer(containerId, sourceFilePath, destinationDirInContainer) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const tarProc = spawn('tar', ['-cf', '-', '-C', path.dirname(sourceFilePath), path.basename(sourceFilePath)], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
tarProc.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
await container.putArchive(tarProc.stdout, { path: destinationDirInContainer });
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
tarProc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `Falha ao empacotar arquivo para copia (codigo ${code})`));
|
||||
});
|
||||
tarProc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async streamArchiveToContainer(containerId, archiveFilePath) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const exec = await container.exec({
|
||||
AttachStdin: true,
|
||||
AttachStdout: false,
|
||||
AttachStderr: true,
|
||||
Tty: false,
|
||||
Cmd: ['sh', '-lc', 'tar --listed-incremental=/dev/null -xzf - -C /'],
|
||||
});
|
||||
|
||||
const attachStream = await exec.start({ hijack: true, stdin: true });
|
||||
const stderrStream = new PassThrough();
|
||||
this.docker.modem.demuxStream(attachStream, new PassThrough(), stderrStream);
|
||||
|
||||
let stderrOutput = '';
|
||||
stderrStream.on('data', (chunk) => {
|
||||
stderrOutput += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
// Pipar o arquivo diretamente para o stdin do tar no container
|
||||
const fileReadStream = fs.createReadStream(archiveFilePath);
|
||||
await new Promise((resolve, reject) => {
|
||||
fileReadStream.on('error', reject);
|
||||
fileReadStream.on('end', () => {
|
||||
try { attachStream.end(); } catch { /* ignore */ }
|
||||
resolve();
|
||||
});
|
||||
fileReadStream.pipe(attachStream, { end: false });
|
||||
});
|
||||
|
||||
// Aguardar tar finalizar
|
||||
let info = await exec.inspect();
|
||||
while (info.Running) {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
info = await exec.inspect();
|
||||
}
|
||||
|
||||
if (typeof attachStream.destroy === 'function') {
|
||||
attachStream.destroy();
|
||||
}
|
||||
|
||||
if (info.ExitCode !== 0) {
|
||||
throw new Error(stderrOutput.trim() || `Falha ao restaurar archive no container (codigo ${info.ExitCode})`);
|
||||
}
|
||||
}
|
||||
|
||||
async copyFileFromContainer(containerId, containerFilePath, localFilePath) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
let archiveStream;
|
||||
try {
|
||||
archiveStream = await container.getArchive({ path: containerFilePath });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(localFilePath), { recursive: true });
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const tarProc = spawn('tar', ['-xOf', '-'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
const writeStream = fs.createWriteStream(localFilePath);
|
||||
archiveStream.pipe(tarProc.stdin);
|
||||
tarProc.stdout.pipe(writeStream);
|
||||
tarProc.on('close', (code) => {
|
||||
if (code === 0) { resolve(); return; }
|
||||
reject(new Error(`Falha ao extrair arquivo do container (codigo ${code})`));
|
||||
});
|
||||
tarProc.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async runHostCommand({ cmd, onOutput }) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn('sh', ['-lc', cmd], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
|
||||
const streamOutput = (stream, streamName) => {
|
||||
let buffer = '';
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
const text = chunk.toString('utf8');
|
||||
output += text;
|
||||
|
||||
if (typeof onOutput !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += text;
|
||||
const parts = buffer.split(/\r?\n/);
|
||||
buffer = parts.pop() || '';
|
||||
for (const line of parts) {
|
||||
onOutput(line, streamName);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (buffer && typeof onOutput === 'function') {
|
||||
onOutput(buffer, streamName);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
streamOutput(child.stdout, 'stdout');
|
||||
streamOutput(child.stderr, 'stderr');
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const trimmedOutput = output.trim();
|
||||
if (code !== 0) {
|
||||
reject(new Error(trimmedOutput || `Comando local terminou com codigo ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(trimmedOutput);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async runHelper({ binds, cmd, onOutput }) {
|
||||
await this.ensureImage();
|
||||
|
||||
const container = await this.docker.createContainer({
|
||||
Image: this.helperImage,
|
||||
Cmd: ['sh', '-lc', cmd],
|
||||
Tty: false,
|
||||
HostConfig: {
|
||||
Binds: binds,
|
||||
AutoRemove: false,
|
||||
NetworkMode: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
let attachStream;
|
||||
|
||||
try {
|
||||
attachStream = await container.attach({ stream: true, stdout: true, stderr: true });
|
||||
const stdoutStream = new PassThrough();
|
||||
const stderrStream = new PassThrough();
|
||||
this.docker.modem.demuxStream(attachStream, stdoutStream, stderrStream);
|
||||
|
||||
const parseOutput = (stream, streamName) => new Promise((resolve) => {
|
||||
let buffer = '';
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
const text = chunk.toString('utf8');
|
||||
output += text;
|
||||
|
||||
if (typeof onOutput !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += text;
|
||||
const parts = buffer.split(/\r?\n/);
|
||||
buffer = parts.pop() || '';
|
||||
|
||||
for (const line of parts) {
|
||||
onOutput(line, streamName);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (buffer && typeof onOutput === 'function') {
|
||||
onOutput(buffer, streamName);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
let output = '';
|
||||
const outputPromises = [
|
||||
parseOutput(stdoutStream, 'stdout'),
|
||||
parseOutput(stderrStream, 'stderr'),
|
||||
];
|
||||
|
||||
await container.start();
|
||||
const result = await container.wait();
|
||||
await Promise.all(outputPromises);
|
||||
|
||||
output = output.trim();
|
||||
|
||||
if (result.StatusCode !== 0) {
|
||||
throw new Error(output || `Helper container terminou com codigo ${result.StatusCode}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
} finally {
|
||||
if (attachStream && typeof attachStream.destroy === 'function') {
|
||||
attachStream.destroy();
|
||||
}
|
||||
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DockerService;
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs/promises');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const config = require('./config');
|
||||
const JsonStore = require('./store');
|
||||
const DockerService = require('./dockerService');
|
||||
const BackupService = require('./backupService');
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(config.dataDir, { recursive: true });
|
||||
|
||||
const store = new JsonStore(config.storePath);
|
||||
await store.init();
|
||||
|
||||
const dockerService = new DockerService({
|
||||
socketPath: config.dockerSocketPath,
|
||||
helperImage: config.helperImage,
|
||||
});
|
||||
|
||||
const backupService = new BackupService({ dockerService, store });
|
||||
const runJobs = new Map();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||
|
||||
app.get('/api/health', (_request, response) => {
|
||||
response.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/containers', async (_request, response) => {
|
||||
try {
|
||||
const containers = await dockerService.listContainers();
|
||||
response.json(containers);
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/profiles', async (_request, response) => {
|
||||
try {
|
||||
const profiles = await store.listProfiles();
|
||||
response.json(profiles);
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/profiles', async (request, response) => {
|
||||
try {
|
||||
const payload = request.body || {};
|
||||
if (!payload.name || !payload.backupDir || !Array.isArray(payload.containerIds) || !payload.containerIds.length) {
|
||||
response.status(400).json({ error: 'Informe nome, diretorio de backup e ao menos um container.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.backupScope && !['volumes', 'container'].includes(payload.backupScope)) {
|
||||
response.status(400).json({ error: 'Tipo de backup invalido.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = payload.id ? await store.getProfile(payload.id) : null;
|
||||
const profile = await store.saveProfile({
|
||||
id: payload.id,
|
||||
createdAt: existing?.createdAt,
|
||||
name: payload.name.trim(),
|
||||
backupDir: payload.backupDir.trim(),
|
||||
containerIds: payload.containerIds,
|
||||
mode: existing?.mode || 'full',
|
||||
backupScope: payload.backupScope || existing?.backupScope || 'volumes',
|
||||
});
|
||||
|
||||
response.status(payload.id ? 200 : 201).json(profile);
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/profiles/:profileId', async (request, response) => {
|
||||
try {
|
||||
const profile = await store.getProfile(request.params.profileId);
|
||||
await store.deleteProfile(request.params.profileId);
|
||||
|
||||
if (profile?.backupDir) {
|
||||
const slugify = (value) => value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item';
|
||||
const profileBackupDir = path.join(profile.backupDir, slugify(profile.name));
|
||||
await fs.rm(profileBackupDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
response.status(204).end();
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/profiles/:profileId/backups', async (request, response) => {
|
||||
try {
|
||||
const backups = await store.listBackups(request.params.profileId);
|
||||
response.json(backups);
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/profiles/:profileId/run', async (request, response) => {
|
||||
try {
|
||||
const profileId = request.params.profileId;
|
||||
const requestedMode = request.body?.mode;
|
||||
if (requestedMode && !['full', 'incremental'].includes(requestedMode)) {
|
||||
response.status(400).json({ error: 'Modo de backup invalido.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running');
|
||||
if (runningJob) {
|
||||
response.status(409).json({ error: 'Ja existe um backup em execucao para este profile.', runId: runningJob.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = crypto.randomUUID();
|
||||
const job = {
|
||||
id: runId,
|
||||
profileId,
|
||||
kind: 'backup',
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
progress: null,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
runJobs.set(runId, job);
|
||||
|
||||
void backupService.runProfile(profileId, {
|
||||
mode: requestedMode,
|
||||
onProgress: (progressSnapshot) => {
|
||||
const currentJob = runJobs.get(runId);
|
||||
if (!currentJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentJob.progress = progressSnapshot;
|
||||
},
|
||||
}).then((backupRun) => {
|
||||
const currentJob = runJobs.get(runId);
|
||||
if (!currentJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentJob.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors';
|
||||
currentJob.result = backupRun;
|
||||
currentJob.finishedAt = new Date().toISOString();
|
||||
}).catch((error) => {
|
||||
const currentJob = runJobs.get(runId);
|
||||
if (!currentJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentJob.status = 'error';
|
||||
currentJob.error = error.message;
|
||||
currentJob.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
response.status(202).json({ runId, status: 'running' });
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/runs/:runId', (request, response) => {
|
||||
const job = runJobs.get(request.params.runId);
|
||||
if (!job) {
|
||||
response.status(404).json({ error: 'Execucao nao encontrada.' });
|
||||
return;
|
||||
}
|
||||
|
||||
response.json({
|
||||
id: job.id,
|
||||
profileId: job.profileId,
|
||||
kind: job.kind || 'backup',
|
||||
status: job.status,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
progress: job.progress,
|
||||
result: job.result,
|
||||
error: job.error,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/profiles/:profileId/restore', async (request, response) => {
|
||||
try {
|
||||
const profileId = request.params.profileId;
|
||||
if (!request.body?.backupId) {
|
||||
response.status(400).json({ error: 'Informe o backup a ser restaurado.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedContainerIds = request.body?.containerIds;
|
||||
if (selectedContainerIds && (!Array.isArray(selectedContainerIds) || !selectedContainerIds.length)) {
|
||||
response.status(400).json({ error: 'Selecione ao menos um container para restaurar.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running');
|
||||
if (runningJob) {
|
||||
response.status(409).json({ error: 'Ja existe uma execucao em andamento para este profile.', runId: runningJob.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = crypto.randomUUID();
|
||||
const job = {
|
||||
id: runId,
|
||||
profileId,
|
||||
kind: 'restore',
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
progress: null,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
runJobs.set(runId, job);
|
||||
|
||||
void backupService.restoreBackup(profileId, request.body.backupId, {
|
||||
selectedContainerIds,
|
||||
onProgress: (progressSnapshot) => {
|
||||
const currentJob = runJobs.get(runId);
|
||||
if (!currentJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentJob.progress = progressSnapshot;
|
||||
},
|
||||
}).then((restoreResult) => {
|
||||
const currentJob = runJobs.get(runId);
|
||||
if (!currentJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentJob.status = restoreResult.status === 'ok' ? 'completed' : 'completed-with-errors';
|
||||
currentJob.result = restoreResult;
|
||||
currentJob.finishedAt = new Date().toISOString();
|
||||
}).catch((error) => {
|
||||
const currentJob = runJobs.get(runId);
|
||||
if (!currentJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentJob.status = 'error';
|
||||
currentJob.error = error.message;
|
||||
currentJob.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
response.status(202).json({ runId, status: 'running', kind: 'restore' });
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
class JsonStore {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath;
|
||||
this.writeQueue = Promise.resolve();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
try {
|
||||
await fs.access(this.filePath);
|
||||
} catch {
|
||||
await fs.writeFile(this.filePath, JSON.stringify({ profiles: [], backups: [] }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async read() {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
parsed.profiles ||= [];
|
||||
parsed.backups ||= [];
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async write(mutator) {
|
||||
this.writeQueue = this.writeQueue.then(async () => {
|
||||
const current = await this.read();
|
||||
const next = await mutator(current);
|
||||
await fs.writeFile(this.filePath, JSON.stringify(next, null, 2));
|
||||
return next;
|
||||
});
|
||||
|
||||
return this.writeQueue;
|
||||
}
|
||||
|
||||
async listProfiles() {
|
||||
const data = await this.read();
|
||||
return data.profiles;
|
||||
}
|
||||
|
||||
async getProfile(profileId) {
|
||||
const data = await this.read();
|
||||
return data.profiles.find((profile) => profile.id === profileId) || null;
|
||||
}
|
||||
|
||||
async saveProfile(profileInput) {
|
||||
const now = new Date().toISOString();
|
||||
const profile = {
|
||||
id: profileInput.id || randomUUID(),
|
||||
name: profileInput.name,
|
||||
containerIds: profileInput.containerIds,
|
||||
mode: profileInput.mode,
|
||||
backupScope: profileInput.backupScope || 'volumes',
|
||||
backupDir: profileInput.backupDir,
|
||||
updatedAt: now,
|
||||
createdAt: profileInput.createdAt || now,
|
||||
};
|
||||
|
||||
await this.write((data) => {
|
||||
const index = data.profiles.findIndex((item) => item.id === profile.id);
|
||||
if (index >= 0) {
|
||||
data.profiles[index] = profile;
|
||||
} else {
|
||||
data.profiles.push(profile);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
async deleteProfile(profileId) {
|
||||
await this.write((data) => {
|
||||
data.profiles = data.profiles.filter((profile) => profile.id !== profileId);
|
||||
data.backups = data.backups.filter((backup) => backup.profileId !== profileId);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
async addBackup(backupRun) {
|
||||
await this.write((data) => {
|
||||
data.backups.unshift(backupRun);
|
||||
return data;
|
||||
});
|
||||
return backupRun;
|
||||
}
|
||||
|
||||
async listBackups(profileId) {
|
||||
const data = await this.read();
|
||||
return data.backups.filter((backup) => backup.profileId === profileId);
|
||||
}
|
||||
|
||||
async getBackup(backupId) {
|
||||
const data = await this.read();
|
||||
return data.backups.find((backup) => backup.id === backupId) || null;
|
||||
}
|
||||
|
||||
async getBackupsForContainer(profileId, containerId, upToBackupId) {
|
||||
const backups = await this.listBackups(profileId);
|
||||
const ordered = backups
|
||||
.slice()
|
||||
.sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime());
|
||||
|
||||
const chain = [];
|
||||
|
||||
for (const backup of ordered) {
|
||||
const containerBackup = backup.containers.find(
|
||||
(item) => item.containerId === containerId && item.status === 'ok' && item.archiveRelativePath,
|
||||
);
|
||||
|
||||
if (!containerBackup) {
|
||||
if (backup.id === upToBackupId) {
|
||||
return chain;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (containerBackup.mode === 'full') {
|
||||
chain.length = 0;
|
||||
}
|
||||
|
||||
chain.push({
|
||||
backupId: backup.id,
|
||||
createdAt: backup.createdAt,
|
||||
...containerBackup,
|
||||
});
|
||||
|
||||
if (backup.id === upToBackupId) {
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
async getLastContainerBackupTime(profileId, containerId) {
|
||||
const backups = await this.listBackups(profileId);
|
||||
const ordered = backups
|
||||
.slice()
|
||||
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime());
|
||||
|
||||
for (const backup of ordered) {
|
||||
const found = backup.containers.find(
|
||||
(item) => item.containerId === containerId && item.status === 'ok',
|
||||
);
|
||||
if (found) {
|
||||
return backup.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = JsonStore;
|
||||
Loading…
Reference in New Issue