Refactor code structure for improved readability and maintainability

This commit is contained in:
Alexander Sabino 2026-05-09 10:43:50 +01:00
parent b763b3ca90
commit 990b0dea4d
9 changed files with 2188 additions and 112 deletions

View File

@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/VERSION-1.0.0-blue?style=flat-square" />
<img src="https://img.shields.io/badge/VERSION-0.0.2-blue?style=flat-square" />
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
@ -18,11 +18,45 @@
> ⚠️ **AVISO CRÍTICO:** Aplicação em estágio inicial de desenvolvimento. Não use em produção — há risco de perda de dados.
Versão atual: **1.0.0**
Versão atual: **0.0.2**
---
## 🗄️ Visão geral
## <20> Changelog
### [0.0.3] — Settings, About, i18n e autenticação
#### Adicionado
- **Configurações:** nova aba com seletor de idioma (10 idiomas) e controle de acesso por usuário/senha
- **Sobre:** nova aba com logo, descrição, versão atual, verificação de última versão via GitHub e botão de atualização automática
- **i18n:** suporte a 10 idiomas — Português (pt-BR), English, Español, Deutsch, Polski, Italiano, Русский, 中文, 日本語, فارسی
- **Autenticação opcional:** todas as rotas da API protegidas por token SHA-256 quando habilitado; endpoints `/api/login` e `/api/auth-status` são públicos
- **Atualização automática:** endpoint `POST /api/update` executa `git pull` e reinicia o container
---
### [0.0.2] — 2026-05-09
#### Adicionado
- **Storage Locations:** nova seção para cadastrar locais de armazenamento (nome + diretório). Agora o diretório de backup é selecionado via dropdown ao criar/editar um profile, em vez de ser digitado manualmente.
- **Backup Incremental — seleção de base:** ao executar um backup incremental com múltiplos backups full disponíveis, um modal é exibido para o usuário escolher qual será usado como base. Com apenas um full disponível, é selecionado automaticamente.
- **Bloqueio de incremental sem full:** o botão de backup incremental é bloqueado com mensagem de aviso caso não exista nenhum backup full realizado para o profile.
- **Agrupamento na aba Backups:** backups incrementais são exibidos agrupados e indentados abaixo do seu respectivo backup full, com badges visuais distintos (verde para Full, amarelo para Incremental).
#### Removido
- Abas **Servers** e **Naming Rules** removidas da interface.
---
### [0.0.1] — inicial
- Cadastro de profiles de backup por container
- Backup full e incremental com GNU tar + `--listed-incremental`
- Restore seletivo de snapshots
- Suporte a escopos `somente volumes` e `container inteiro`
- Suporte a Docker API nativa (`getArchive`/`putArchive`) quando rodando dentro de container
---
## <20>🗄 Visão geral
O `dockerbackup` fornece:

View File

@ -1,6 +1,6 @@
{
"name": "dockerbackup-app",
"version": "1.0.0",
"version": "0.0.3",
"description": "Aplicacao web para backup e restauracao de volumes Docker",
"main": "src/server.js",
"scripts": {

View File

@ -1,6 +1,36 @@
// ─── i18n ─────────────────────────────────────────────────
const TRANSLATIONS = window.TRANSLATIONS || {};
const LOCALE_NAMES = window.LOCALE_NAMES || {};
let currentLang = localStorage.getItem('lang') || 'pt-BR';
function t(key) {
return (TRANSLATIONS[currentLang] || TRANSLATIONS['pt-BR'] || {})[key] || key;
}
function applyTranslations() {
document.documentElement.lang = currentLang;
document.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.dataset.i18n;
el.textContent = t(key);
});
}
// ─── Auth ──────────────────────────────────────────────────
let authToken = localStorage.getItem('authToken') || null;
function getAuthHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (authToken) {
headers['x-auth-token'] = authToken;
}
return headers;
}
const state = {
containers: [],
profiles: [],
storageLocations: [],
activeRuns: new Map(),
volumeSelections: {},
};
@ -11,7 +41,7 @@ const elements = {
profileForm: document.querySelector('#profileForm'),
profileId: document.querySelector('#profileId'),
profileName: document.querySelector('#profileName'),
backupDir: document.querySelector('#backupDir'),
storageLocationSelect: document.querySelector('#storageLocationId'),
containerOptions: document.querySelector('#containerOptions'),
profilesList: document.querySelector('#profilesList'),
toast: document.querySelector('#toast'),
@ -29,6 +59,16 @@ const elements = {
volumePickerConfirm: document.querySelector('#volumePickerConfirm'),
volumePickerClose: document.querySelector('#volumePickerClose'),
volumePickerSelectAll: document.querySelector('#volumePickerSelectAll'),
fullBackupPickerModal: document.querySelector('#fullBackupPickerModal'),
fullBackupPickerOptions: document.querySelector('#fullBackupPickerOptions'),
fullBackupPickerConfirm: document.querySelector('#fullBackupPickerConfirm'),
fullBackupPickerClose: document.querySelector('#fullBackupPickerClose'),
storageLocationFormModal: document.querySelector('#storageLocationFormModal'),
storageLocationForm: document.querySelector('#storageLocationForm'),
storageLocationName: document.querySelector('#storageLocationName'),
storageLocationDir: document.querySelector('#storageLocationDir'),
storageLocationIdField: document.querySelector('#storageFormId'),
storageLocationsList: document.querySelector('#storageLocationsList'),
};
// ─── View navigation ──────────────────────────────────────
@ -47,15 +87,21 @@ function navigateTo(viewName) {
loadProfiles();
loadContainers();
}
if (viewName === 'servers') {
renderServers();
}
if (viewName === 'runs') {
loadAllRuns();
}
if (viewName === 'backups') {
renderBackupsView();
}
if (viewName === 'storage') {
loadStorageLocations();
}
if (viewName === 'settings') {
loadSettingsView();
}
if (viewName === 'about') {
loadAboutView();
}
}
document.querySelector('.sidebar').addEventListener('click', (e) => {
@ -66,7 +112,6 @@ document.querySelector('.sidebar').addEventListener('click', (e) => {
});
document.querySelector('#createProfileBtn')?.addEventListener('click', () => navigateTo('profiles'));
document.querySelector('#refreshServers')?.addEventListener('click', () => renderServers());
// ─── Profile form modal ───────────────────────────────────
function openProfileModal(title = 'Novo Profile') {
@ -82,6 +127,7 @@ function closeProfileModal() {
document.querySelector('#openCreateProfileModal')?.addEventListener('click', () => {
resetForm();
populateStorageLocationDropdown();
openProfileModal('Novo Profile');
});
@ -93,20 +139,185 @@ elements.profileFormModal?.addEventListener('click', (e) => {
}
});
function renderServers() {
const list = document.querySelector('#serversList');
// ─── Storage Locations ────────────────────────────────────
async function loadStorageLocations() {
state.storageLocations = await api('/api/storage-locations');
renderStorageLocationsList();
populateStorageLocationDropdown();
}
function renderStorageLocationsList() {
const list = elements.storageLocationsList;
if (!list) return;
if (!state.containers.length) {
list.innerHTML = '<p class="empty-state">Nenhum servidor encontrado.</p>';
if (!state.storageLocations.length) {
list.innerHTML = '<p class="empty-state">Nenhum local de armazenamento configurado. Crie um para poder configurar backup profiles.</p>';
return;
}
list.innerHTML = state.containers.map((c) => `
<div class="server-card">
<h3>${escapeHtml(c.name)}</h3>
<small>${escapeHtml(c.image)}</small>
<small>${escapeHtml(c.status)} · <span class="state ${escapeHtml(c.state)}">${escapeHtml(c.state)}</span></small>
</div>
list.innerHTML = `
<table class="data-table">
<thead><tr><th>Nome</th><th>Diretório</th><th>Ações</th></tr></thead>
<tbody>
${state.storageLocations.map((loc) => `
<tr>
<td><strong>${escapeHtml(loc.name)}</strong></td>
<td><code>${escapeHtml(loc.directory)}</code></td>
<td>
<button class="btn btn--ghost btn--sm" data-storage-action="delete" data-storage-id="${escapeHtml(loc.id)}">Excluir</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function populateStorageLocationDropdown() {
const select = elements.storageLocationSelect;
if (!select) return;
const current = select.value;
select.innerHTML = '<option value="">Selecione um local...</option>' +
state.storageLocations.map((loc) =>
`<option value="${escapeHtml(loc.id)}">${escapeHtml(loc.name)}${escapeHtml(loc.directory)}</option>`
).join('');
if (current) select.value = current;
}
function openStorageModal() {
document.querySelector('#storageModalTitle').textContent = 'Novo Local de Armazenamento';
elements.storageLocationForm.reset();
elements.storageLocationIdField.value = '';
elements.storageLocationFormModal.classList.remove('hidden');
elements.storageLocationFormModal.setAttribute('aria-hidden', 'false');
}
function closeStorageModal() {
elements.storageLocationFormModal.classList.add('hidden');
elements.storageLocationFormModal.setAttribute('aria-hidden', 'true');
}
async function saveStorageLocation(event) {
event.preventDefault();
const payload = {
id: elements.storageLocationIdField.value || undefined,
name: elements.storageLocationName.value.trim(),
directory: elements.storageLocationDir.value.trim(),
};
try {
await api('/api/storage-locations', {
method: 'POST',
body: JSON.stringify(payload),
});
closeStorageModal();
await loadStorageLocations();
showToast('Local de armazenamento salvo.');
} catch (error) {
showToast(error.message, true);
}
}
document.querySelector('#openCreateStorageModal')?.addEventListener('click', openStorageModal);
document.querySelector('#cancelStorageForm')?.addEventListener('click', closeStorageModal);
document.querySelector('#storageModalClose')?.addEventListener('click', closeStorageModal);
elements.storageLocationFormModal?.addEventListener('click', (e) => {
if (e.target.closest('[data-action="close-storage-modal"]')) closeStorageModal();
});
elements.storageLocationForm?.addEventListener('submit', saveStorageLocation);
elements.storageLocationsList?.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-storage-action="delete"]');
if (!btn) return;
const id = btn.dataset.storageId;
if (!window.confirm('Excluir este local de armazenamento?')) return;
try {
await api(`/api/storage-locations/${id}`, { method: 'DELETE' });
await loadStorageLocations();
showToast('Local removido.');
} catch (error) {
showToast(error.message, true);
}
});
// ─── Full Backup Picker Modal ─────────────────────────────
function askFullBackupSelection(fullBackups, profileName) {
elements.fullBackupPickerOptions.innerHTML = fullBackups.map((b) => `
<label class="modal-option">
<input type="radio" name="fullBackupChoice" value="${escapeHtml(b.id)}" />
<span>
<strong>${escapeHtml(new Date(b.createdAt).toLocaleString('pt-BR'))}</strong>
<small>${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))} · ${escapeHtml(b.status)}</small>
</span>
</label>
`).join('');
// Pre-select the most recent one
const firstRadio = elements.fullBackupPickerOptions.querySelector('input[name="fullBackupChoice"]');
if (firstRadio) firstRadio.checked = true;
elements.fullBackupPickerModal.classList.remove('hidden');
elements.fullBackupPickerModal.setAttribute('aria-hidden', 'false');
return new Promise((resolve) => {
const closeModal = () => {
elements.fullBackupPickerModal.classList.add('hidden');
elements.fullBackupPickerModal.setAttribute('aria-hidden', 'true');
elements.fullBackupPickerOptions.innerHTML = '';
};
const cleanup = () => {
elements.fullBackupPickerConfirm.removeEventListener('click', onConfirm);
elements.fullBackupPickerClose.removeEventListener('click', onCancel);
elements.fullBackupPickerModal.removeEventListener('click', onBackdropClick);
};
const onConfirm = () => {
const selected = elements.fullBackupPickerOptions.querySelector('input[name="fullBackupChoice"]:checked');
if (!selected) {
showToast('Selecione um backup full.', true);
return;
}
cleanup();
closeModal();
resolve(selected.value);
};
const onCancel = () => {
cleanup();
closeModal();
resolve(null);
};
const onBackdropClick = (event) => {
if (event.target.closest('[data-action="close-full-backup-picker"]')) onCancel();
};
elements.fullBackupPickerConfirm.addEventListener('click', onConfirm);
elements.fullBackupPickerClose.addEventListener('click', onCancel);
elements.fullBackupPickerModal.addEventListener('click', onBackdropClick);
});
}
async function resolveFullBackupId(profileId, profile) {
const backups = await api(`/api/profiles/${profileId}/backups`);
const fullBackups = backups
.filter((b) => b.mode === 'full' && (b.status === 'ok' || b.status === 'partial'))
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
if (!fullBackups.length) {
showToast('Não há backup full disponível. Execute um backup full primeiro.', true);
return undefined; // signal: blocked
}
if (fullBackups.length === 1) {
return fullBackups[0].id;
}
return askFullBackupSelection(fullBackups, profile.name);
}
function renderServers() {
// Servers view was removed
}
async function renderBackupsView() {
@ -120,22 +331,48 @@ async function renderBackupsView() {
const backups = await api(`/api/profiles/${p.id}/backups`);
return { profile: p, backups };
}));
host.innerHTML = rows.map(({ profile, backups }) => `
host.innerHTML = rows.map(({ profile, backups }) => {
const groups = groupBackupsByFull(backups);
const totalBackups = backups.length;
const groupsHtml = groups.length
? groups.map(({ full, incrementals }) => {
const allInGroup = [full, ...incrementals];
return `
<tbody>
${renderBackupRow(full, profile, true)}
${incrementals.map((inc) => renderBackupRow(inc, profile, false)).join('')}
</tbody>
`;
}).join('')
: `<tbody><tr><td colspan="5" class="empty-row">Nenhum backup realizado.</td></tr></tbody>`;
return `
<div class="card">
<div class="card-toolbar">
<h2 class="card-title">${escapeHtml(profile.name)}</h2>
<span class="badge">${escapeHtml(String(backups.length))} backup(s)</span>
<span class="badge">${escapeHtml(String(totalBackups))} backup(s)</span>
</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Data</th><th>Mode</th><th>Status</th><th>Containers</th><th>Actions</th></tr></thead>
<tbody>
${backups.length ? backups.map((b) => {
<thead><tr><th>Data</th><th>Tipo</th><th>Status</th><th>Containers</th><th>Ações</th></tr></thead>
${groupsHtml}
</table>
</div>
</div>
`;
}).join('');
}
function renderBackupRow(b, profile, isFull) {
const hasRestorable = (b.containers || []).some((c) => c.status === 'ok');
const indent = isFull ? '' : '&nbsp;&nbsp;&nbsp;↳&nbsp;';
const modeLabel = isFull ? 'Full' : 'Incremental';
return `
<tr>
<td>${escapeHtml(new Date(b.createdAt).toLocaleString('pt-BR'))}</td>
<td>${escapeHtml(b.mode || '—')}</td>
<tr${isFull ? '' : ' class="incremental-row"'}>
<td>${indent}${escapeHtml(new Date(b.createdAt).toLocaleString('pt-BR'))}</td>
<td><span class="badge badge--${escapeHtml(b.mode || 'full')}">${escapeHtml(modeLabel)}</span></td>
<td><span class="status-badge status-badge--${escapeHtml(b.status)}">${escapeHtml(b.status)}</span></td>
<td>${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))}</td>
<td>
@ -148,12 +385,27 @@ async function renderBackupsView() {
>Restore</button>
</td>
</tr>
`}).join('') : '<tr><td colspan="5" class="empty-row">Nenhum backup realizado.</td></tr>'}
</tbody>
</table>
</div>
</div>
`).join('');
`;
}
function groupBackupsByFull(backups) {
const chronological = [...backups].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const groupMap = new Map();
let currentFullId = null;
for (const backup of chronological) {
if (backup.mode === 'full') {
currentFullId = backup.id;
groupMap.set(currentFullId, { full: backup, incrementals: [] });
} else if (backup.mode === 'incremental') {
const targetId = backup.basedOnFullBackupId || currentFullId;
if (targetId && groupMap.has(targetId)) {
groupMap.get(targetId).incrementals.push(backup);
}
}
}
return [...groupMap.values()].sort((a, b) => new Date(b.full.createdAt) - new Date(a.full.createdAt));
}
async function updateDashboard() {
@ -277,12 +529,19 @@ async function loadAllRuns() {
async function api(path, options = {}) {
const response = await fetch(path, {
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
...(options.headers || {}),
},
...options,
});
if (response.status === 401) {
authToken = null;
localStorage.removeItem('authToken');
showLoginOverlay();
throw new Error('Sessao expirada. Faca login novamente.');
}
if (response.status === 204) {
return null;
}
@ -788,7 +1047,10 @@ function resetForm() {
function fillForm(profile) {
elements.profileId.value = profile.id;
elements.profileName.value = profile.name;
elements.backupDir.value = profile.backupDir;
populateStorageLocationDropdown();
if (profile.storageLocationId) {
elements.storageLocationSelect.value = profile.storageLocationId;
}
const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
state.volumeSelections = Object.assign({}, profile.volumeSelections || {});
@ -816,6 +1078,12 @@ async function saveProfile(event) {
event.preventDefault();
const selectedContainerIds = getSelectedContainerIds();
const backupScope = document.querySelector('input[name="backupScope"]:checked').value;
const storageLocationId = elements.storageLocationSelect.value;
if (!storageLocationId) {
showToast('Selecione um local de armazenamento.', true);
return;
}
const volumeSelections = {};
if (backupScope === 'volumes') {
@ -829,7 +1097,7 @@ async function saveProfile(event) {
const payload = {
id: elements.profileId.value || undefined,
name: elements.profileName.value,
backupDir: elements.backupDir.value,
storageLocationId,
containerIds: selectedContainerIds,
backupScope,
volumeSelections,
@ -882,7 +1150,25 @@ async function handleProfileAction(event) {
button.textContent = 'Executando...';
const mode = getRunMode(profileId);
const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode }) });
let basedOnFullBackupId = null;
if (mode === 'incremental') {
const result = await resolveFullBackupId(profileId, profile);
if (result === undefined) {
// Blocked: no full backup available
button.disabled = false;
button.textContent = 'Run';
return;
}
if (result === null) {
// User cancelled modal
button.disabled = false;
button.textContent = 'Run';
return;
}
basedOnFullBackupId = result;
}
const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode, basedOnFullBackupId }) });
state.activeRuns.set(profileId, {
id: start.runId,
profileId,
@ -993,9 +1279,202 @@ async function handleProfileAction(event) {
}
}
async function init() {
// ─── Login overlay ────────────────────────────────────────
function showLoginOverlay() {
const overlay = document.querySelector('#loginOverlay');
if (!overlay) return;
overlay.classList.remove('hidden');
overlay.setAttribute('aria-hidden', 'false');
document.querySelector('#logoutBtn')?.classList.add('hidden');
}
function hideLoginOverlay() {
const overlay = document.querySelector('#loginOverlay');
if (!overlay) return;
overlay.classList.add('hidden');
overlay.setAttribute('aria-hidden', 'true');
}
document.querySelector('#loginForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.querySelector('#loginUsername')?.value || '';
const password = document.querySelector('#loginPassword')?.value || '';
const errorEl = document.querySelector('#loginError');
try {
await Promise.all([loadContainers(), loadProfiles()]);
const result = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await result.json();
if (!result.ok) {
errorEl?.classList.remove('hidden');
return;
}
errorEl?.classList.add('hidden');
authToken = data.token;
if (authToken) localStorage.setItem('authToken', authToken);
hideLoginOverlay();
document.querySelector('#logoutBtn')?.classList.remove('hidden');
await init();
} catch {
errorEl?.classList.remove('hidden');
}
});
document.querySelector('#logoutBtn')?.addEventListener('click', () => {
authToken = null;
localStorage.removeItem('authToken');
showLoginOverlay();
});
async function checkAuthAndInit() {
try {
const status = await fetch('/api/auth-status').then((r) => r.json());
if (!status.requireAuth) {
hideLoginOverlay();
await init();
return;
}
// Auth required
if (authToken) {
// Try using stored token
const probe = await fetch('/api/profiles', { headers: { 'x-auth-token': authToken } });
if (probe.ok) {
hideLoginOverlay();
document.querySelector('#logoutBtn')?.classList.remove('hidden');
await init();
return;
}
// Token invalid
authToken = null;
localStorage.removeItem('authToken');
}
showLoginOverlay();
} catch {
// If health check fails, still show the app (might be first load)
await init();
}
}
// ─── Settings ─────────────────────────────────────────────
function buildLanguageSelect() {
const select = document.querySelector('#settingsLanguage');
if (!select) return;
select.innerHTML = Object.entries(LOCALE_NAMES).map(([code, name]) =>
`<option value="${code}">${name}</option>`
).join('');
select.value = currentLang;
}
async function loadSettingsView() {
buildLanguageSelect();
try {
const settings = await api('/api/settings');
const select = document.querySelector('#settingsLanguage');
if (select && settings.language) select.value = settings.language;
const authCheck = document.querySelector('#settingsRequireAuth');
if (authCheck) authCheck.checked = settings.requireAuth;
const authFields = document.querySelector('#authFields');
if (authFields) authFields.classList.toggle('hidden', !settings.requireAuth);
const usernameField = document.querySelector('#settingsUsername');
if (usernameField) usernameField.value = settings.username || '';
} catch {
// Non-fatal: use defaults
}
}
document.querySelector('#settingsRequireAuth')?.addEventListener('change', (e) => {
document.querySelector('#authFields')?.classList.toggle('hidden', !e.target.checked);
});
document.querySelector('#saveSettingsBtn')?.addEventListener('click', async () => {
const language = document.querySelector('#settingsLanguage')?.value || currentLang;
const requireAuth = document.querySelector('#settingsRequireAuth')?.checked || false;
const username = document.querySelector('#settingsUsername')?.value?.trim() || '';
const password = document.querySelector('#settingsPassword')?.value || '';
try {
await api('/api/settings', {
method: 'POST',
body: JSON.stringify({ language, requireAuth, username, password: password || undefined }),
});
currentLang = language;
localStorage.setItem('lang', language);
applyTranslations();
showToast(t('settings.saved'));
} catch (error) {
showToast(error.message, true);
}
});
// ─── About ────────────────────────────────────────────────
async function loadAboutView() {
const currentVerEl = document.querySelector('#aboutCurrentVersion');
const latestVerEl = document.querySelector('#aboutLatestVersion');
const updateWrap = document.querySelector('#aboutUpdateWrap');
const updateStatus = document.querySelector('#aboutUpdateStatus');
const updateBtn = document.querySelector('#aboutUpdateBtn');
try {
const about = await api('/api/about');
const current = about.currentVersion || '—';
if (currentVerEl) currentVerEl.textContent = current;
// Fetch latest from GitHub
if (latestVerEl) latestVerEl.textContent = t('about.checking');
try {
const ghRes = await fetch('https://api.github.com/repos/asabino2/dockerbackup/tags', {
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (ghRes.ok) {
const tags = await ghRes.json();
const latestTag = tags[0]?.name?.replace(/^v/, '') || null;
if (latestVerEl) latestVerEl.textContent = latestTag || '—';
if (updateWrap) updateWrap.classList.remove('hidden');
if (latestTag && current !== latestTag) {
if (updateStatus) updateStatus.textContent = t('about.updateAvailable');
if (updateBtn) updateBtn.classList.remove('hidden');
} else {
if (updateStatus) updateStatus.textContent = t('about.upToDate');
if (updateBtn) updateBtn.classList.add('hidden');
}
} else {
if (latestVerEl) latestVerEl.textContent = '—';
if (updateStatus) updateStatus.textContent = t('about.checkError');
if (updateWrap) updateWrap.classList.remove('hidden');
}
} catch {
if (latestVerEl) latestVerEl.textContent = '—';
if (updateStatus) updateStatus.textContent = t('about.checkError');
if (updateWrap) updateWrap.classList.remove('hidden');
}
} catch (error) {
if (currentVerEl) currentVerEl.textContent = '—';
showToast(error.message, true);
}
}
document.querySelector('#aboutUpdateBtn')?.addEventListener('click', async () => {
const btn = document.querySelector('#aboutUpdateBtn');
const status = document.querySelector('#aboutUpdateStatus');
if (btn) { btn.disabled = true; btn.textContent = t('about.updating'); }
try {
await api('/api/update', { method: 'POST' });
if (status) status.textContent = t('about.updateSuccess');
showToast(t('about.updateSuccess'));
} catch (error) {
if (btn) { btn.disabled = false; btn.textContent = t('about.update'); }
if (status) status.textContent = t('about.updateError');
showToast(error.message, true);
}
});
async function init() {
applyTranslations();
try {
await Promise.all([loadContainers(), loadProfiles(), loadStorageLocations()]);
await updateDashboard();
} catch (error) {
showToast(error.message, true);
@ -1011,4 +1490,6 @@ elements.profilesList.addEventListener('click', handleProfileAction);
document.querySelector('#backupsViewList').addEventListener('click', handleProfileAction);
document.querySelector('#refreshRuns')?.addEventListener('click', () => loadAllRuns());
init();
// Apply translations early (before auth check so login page is translated)
applyTranslations();
checkAuthAndInit();

View File

@ -28,9 +28,9 @@
</button>
</li>
<li>
<button class="nav-item" data-view="servers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="4" rx="1"/><rect x="2" y="10" width="20" height="4" rx="1"/><rect x="2" y="17" width="20" height="4" rx="1"/></svg>
<span>Servers</span>
<button class="nav-item" data-view="storage">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<span>Storage Locations</span>
</button>
</li>
<li>
@ -48,30 +48,27 @@
<li>
<button class="nav-item" data-view="backups">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<span>Backups</span>
<span data-i18n="nav.backups">Backups</span>
</button>
</li>
<li>
<button class="nav-item" data-view="settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span data-i18n="nav.settings">Configurações</span>
</button>
</li>
<li>
<button class="nav-item" data-view="about">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-i18n="nav.about">Sobre</span>
</button>
</li>
</ul>
<div class="nav-section-label">CONFIGURATION</div>
<ul class="nav-list">
<li>
<button class="nav-item" data-view="storage">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<span>Storage Locations</span>
</button>
</li>
<li>
<button class="nav-item" data-view="naming">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span>Naming Rules</span>
</button>
</li>
</ul>
<div class="sidebar-footer">
<button id="logoutBtn" class="btn btn--ghost btn--sm sidebar-logout hidden" data-i18n="login.logout">Sair</button>
</div>
</nav>
<!-- ===== MAIN CONTENT ===== -->
<div class="main-content">
<!-- VIEW: Dashboard -->
@ -153,15 +150,6 @@
</div>
</div>
<!-- VIEW: Servers -->
<div id="view-servers" class="view hidden">
<div class="page-header">
<h1 class="page-title">Servers</h1>
<button id="refreshServers" class="btn btn--secondary">Refresh</button>
</div>
<div id="serversList" class="servers-grid"></div>
</div>
<!-- VIEW: Backup Profiles -->
<div id="view-profiles" class="view hidden">
<div class="page-header">
@ -217,16 +205,103 @@
<div id="backupsViewList"></div>
</div>
<!-- VIEW: Storage -->
<!-- VIEW: Storage Locations -->
<div id="view-storage" class="view hidden">
<div class="page-header"><h1 class="page-title">Storage Locations</h1></div>
<div class="card"><p class="empty-state">Sem locais de armazenamento configurados.</p></div>
<div class="page-header">
<h1 class="page-title" data-i18n="storage.title">Storage Locations</h1>
<button id="openCreateStorageModal" class="btn btn--primary" data-i18n="storage.new">+ Novo Local</button>
</div>
<div class="card" id="storageLocationsCard">
<div id="storageLocationsList"></div>
</div>
</div>
<!-- VIEW: Naming -->
<div id="view-naming" class="view hidden">
<div class="page-header"><h1 class="page-title">Naming Rules</h1></div>
<div class="card"><p class="empty-state">Sem regras de nomenclatura configuradas.</p></div>
<!-- VIEW: Settings -->
<div id="view-settings" class="view hidden">
<div class="page-header">
<h1 class="page-title" data-i18n="settings.title">Configurações</h1>
</div>
<div class="card settings-card">
<div class="settings-section">
<h2 data-i18n="settings.language">Idioma</h2>
<p data-i18n="settings.languageDesc">Selecione o idioma de toda a interface</p>
<select id="settingsLanguage" class="settings-select">
</select>
</div>
<div class="settings-section">
<h2 data-i18n="settings.auth">Controle de Acesso</h2>
<label class="toggle-label">
<input type="checkbox" id="settingsRequireAuth" />
<span data-i18n="settings.authEnabled">Exigir usuário e senha para acesso</span>
</label>
<div id="authFields" class="auth-fields hidden">
<div class="form-field">
<label for="settingsUsername" data-i18n="settings.username">Usuário</label>
<input id="settingsUsername" type="text" autocomplete="username" />
</div>
<div class="form-field">
<label for="settingsPassword" data-i18n="settings.password">Senha (deixe em branco para não alterar)</label>
<input id="settingsPassword" type="password" autocomplete="new-password" />
</div>
</div>
</div>
<div class="form-actions">
<button id="saveSettingsBtn" class="btn btn--primary" data-i18n="settings.saveSettings">Salvar configurações</button>
</div>
</div>
</div>
<!-- VIEW: About -->
<div id="view-about" class="view hidden">
<div class="page-header">
<h1 class="page-title" data-i18n="about.title">Sobre</h1>
</div>
<div class="card about-card">
<div class="about-logo-wrap">
<img src="/icon.png" class="about-logo" alt="DockerBackup" />
<h2>DockerBackup</h2>
</div>
<p class="about-description" data-i18n="about.description">Aplicação web para backup e restauração de volumes Docker com suporte a snapshots incrementais e restore seletivo.</p>
<div class="about-version-grid">
<div class="about-version-item">
<span class="about-version-label" data-i18n="about.currentVersion">Versão atual</span>
<strong id="aboutCurrentVersion"></strong>
</div>
<div class="about-version-item">
<span class="about-version-label" data-i18n="about.latestVersion">Última versão</span>
<strong id="aboutLatestVersion" data-i18n="about.checking">Verificando...</strong>
</div>
</div>
<div id="aboutUpdateWrap" class="about-update-wrap hidden">
<span id="aboutUpdateStatus" class="about-update-status"></span>
<button id="aboutUpdateBtn" class="btn btn--primary hidden" data-i18n="about.update">Atualizar agora</button>
</div>
<div class="about-changelog">
<h3 data-i18n="about.changelog">Últimas alterações</h3>
<div id="aboutChangelog" class="changelog-content">
<h4>0.0.3</h4>
<ul>
<li>Suporte a múltiplos idiomas (10 idiomas)</li>
<li>Aba de configurações com controle de acesso (usuário e senha)</li>
<li>Aba "Sobre" com verificação de versão via GitHub e atualização automática</li>
</ul>
<h4>0.0.2</h4>
<ul>
<li>Adicionado gerenciamento de Storage Locations</li>
<li>Backup incremental com seleção de full backup base</li>
<li>Agrupamento visual de backups incrementais sob o full</li>
<li>Removidas abas Servers e Naming Rules</li>
</ul>
<h4>0.0.1</h4>
<ul>
<li>Versão inicial: backup e restore de volumes Docker</li>
</ul>
</div>
</div>
</div>
</div>
</div><!-- /.main-content -->
@ -234,6 +309,27 @@
<aside id="toast" class="toast hidden"></aside>
<!-- Login overlay -->
<div id="loginOverlay" class="login-overlay hidden" aria-hidden="true">
<div class="login-card" role="dialog" aria-modal="true" aria-labelledby="loginTitle">
<img src="/icon.png" class="login-logo" alt="DockerBackup" />
<h2 id="loginTitle" data-i18n="login.title">Acesso restrito</h2>
<p data-i18n="login.subtitle">Faça login para continuar</p>
<form id="loginForm" class="login-form">
<div class="form-field">
<label for="loginUsername" data-i18n="login.username">Usuário</label>
<input id="loginUsername" type="text" autocomplete="username" required />
</div>
<div class="form-field">
<label for="loginPassword" data-i18n="login.password">Senha</label>
<input id="loginPassword" type="password" autocomplete="current-password" required />
</div>
<p id="loginError" class="login-error hidden" data-i18n="login.error">Usuário ou senha incorretos.</p>
<button class="btn btn--primary btn--full" type="submit" data-i18n="login.submit">Entrar</button>
</form>
</div>
</div>
<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">
@ -282,8 +378,10 @@
</div>
<div class="form-field">
<label for="backupDir">Diretório de backup no host Docker</label>
<input id="backupDir" name="backupDir" type="text" placeholder="/srv/docker-backups" required />
<label for="storageLocationId">Local de armazenamento</label>
<select id="storageLocationId" name="storageLocationId" required>
<option value="">Selecione um local...</option>
</select>
</div>
<fieldset class="form-fieldset">
@ -318,6 +416,49 @@
</div>
</div>
<!-- Modal: Selecionar backup full base para incremental -->
<div id="fullBackupPickerModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-full-backup-picker"></div>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="fullBackupPickerTitle">
<div class="modal-header">
<h3 id="fullBackupPickerTitle">Selecionar backup full base</h3>
<button id="fullBackupPickerClose" class="btn btn--ghost btn--sm" type="button" data-action="close-full-backup-picker">Fechar</button>
</div>
<p class="modal-subtitle">Selecione o backup full que será utilizado como base para o backup incremental:</p>
<div id="fullBackupPickerOptions" class="modal-options"></div>
<div class="modal-actions">
<button id="fullBackupPickerConfirm" class="btn btn--primary" type="button">Confirmar</button>
</div>
</div>
</div>
<!-- Modal: Criar/Editar Storage Location -->
<div id="storageLocationFormModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-storage-modal"></div>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="storageModalTitle">
<div class="modal-header">
<h3 id="storageModalTitle">Novo Local de Armazenamento</h3>
<button id="storageModalClose" class="btn btn--ghost btn--sm" type="button" data-action="close-storage-modal">Fechar</button>
</div>
<form id="storageLocationForm" class="profile-form">
<input type="hidden" id="storageFormId" />
<div class="form-field">
<label for="storageLocationName">Nome</label>
<input id="storageLocationName" type="text" placeholder="Backup principal" required />
</div>
<div class="form-field">
<label for="storageLocationDir">Diretório</label>
<input id="storageLocationDir" type="text" placeholder="/srv/docker-backups" required />
</div>
<div class="form-actions">
<button class="btn btn--primary" type="submit">Salvar</button>
<button class="btn btn--secondary" type="button" id="cancelStorageForm">Cancelar</button>
</div>
</form>
</div>
</div>
<script src="/translations.js"></script>
<script src="/app.js"></script>
</body>
</html>

View File

@ -725,6 +725,27 @@ button, input, select {
color: #2b6cb0;
}
.badge--full {
background: #e6ffed;
color: #276749;
}
.badge--incremental {
background: #fef3c7;
color: #92400e;
}
/* --- Incremental backup row ------------------------------ */
.incremental-row td:first-child {
padding-left: 28px;
color: var(--text-muted);
font-size: 13px;
}
.incremental-row {
background: rgba(0,0,0,0.015);
}
/* --- Empty states ---------------------------------------- */
.empty-state {
padding: 24px;
@ -783,6 +804,223 @@ button, input, select {
.hidden { display: none !important; }
/* --- Sidebar footer / logout ----------------------------- */
.sidebar-footer {
margin-top: auto;
padding: 12px 16px 16px;
border-top: 1px solid rgba(255,255,255,0.07);
}
.sidebar-logout {
width: 100%;
justify-content: center;
color: var(--sidebar-text);
}
.sidebar-logout:hover { color: #fff; }
/* --- Login overlay --------------------------------------- */
.login-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(15, 15, 30, 0.85);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px 36px 36px;
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
}
.login-logo {
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 12px;
margin-bottom: 4px;
}
.login-card h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
}
.login-card p {
font-size: 0.875rem;
color: var(--text-muted);
}
.login-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 8px;
}
.login-error {
font-size: 0.8rem;
color: var(--danger);
text-align: center;
}
.btn--full { width: 100%; justify-content: center; }
/* --- Settings view --------------------------------------- */
.settings-card {
max-width: 640px;
display: flex;
flex-direction: column;
gap: 28px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.settings-section h2 {
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.settings-section p {
font-size: 0.85rem;
color: var(--text-muted);
}
.settings-select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.875rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
max-width: 280px;
}
.settings-select:focus { outline: 2px solid var(--accent); }
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text);
}
.toggle-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
.auth-fields {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
}
/* --- About view ------------------------------------------ */
.about-card {
max-width: 600px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.about-logo-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.about-logo {
width: 80px;
height: 80px;
object-fit: contain;
border-radius: 16px;
}
.about-logo-wrap h2 {
font-size: 1.4rem;
font-weight: 700;
color: var(--text);
}
.about-description {
font-size: 0.9rem;
color: var(--text-muted);
max-width: 480px;
line-height: 1.6;
}
.about-version-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
max-width: 380px;
}
.about-version-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
}
.about-version-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .03em;
}
.about-version-item strong {
font-size: 1.1rem;
color: var(--text);
}
.about-update-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 100%;
}
.about-update-status {
font-size: 0.875rem;
color: var(--text-muted);
}
.about-changelog {
width: 100%;
text-align: left;
border-top: 1px solid var(--border);
padding-top: 16px;
}
.about-changelog h3 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.changelog-content h4 {
font-size: 0.85rem;
font-weight: 600;
color: var(--accent);
margin: 10px 0 6px;
}
.changelog-content ul {
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.changelog-content li {
font-size: 0.82rem;
color: var(--text-muted);
}
/* --- Modal ----------------------------------------------- */
.modal {
position: fixed;

939
public/translations.js Normal file
View File

@ -0,0 +1,939 @@
(function () {
window.LOCALE_NAMES = {
'pt-BR': 'Português (Brasil)',
'en': 'English',
'es': 'Español',
'de': 'Deutsch',
'pl': 'Polski',
'it': 'Italiano',
'ru': 'Русский',
'zh': '中文',
'ja': '日本語',
'fa': 'فارسی',
};
const ptBR = {
'nav.dashboard': 'Dashboard',
'nav.storage': 'Storage Locations',
'nav.profiles': 'Backup Profiles',
'nav.runs': 'Backup Runs',
'nav.backups': 'Backups',
'nav.settings': 'Configurações',
'nav.about': 'Sobre',
'dashboard.title': 'Dashboard',
'dashboard.totalContainers': 'Total Containers',
'dashboard.activeConnections': 'Conexões ativas',
'dashboard.backupProfiles': 'Backup Profiles',
'dashboard.configuredProfiles': 'Profiles configurados',
'dashboard.successful': 'Bem-sucedidos',
'dashboard.totalSuccessful': 'Total com sucesso',
'dashboard.failed': 'Falhas',
'dashboard.totalFailed': 'Total com falha',
'dashboard.recentRuns': 'Execuções Recentes',
'dashboard.createProfile': '+ Criar Profile',
'table.id': 'ID',
'table.profile': 'Profile',
'table.mode': 'Modo',
'table.status': 'Status',
'table.containers': 'Containers',
'table.files': 'Arquivos',
'table.size': 'Tamanho',
'table.started': 'Início',
'table.duration': 'Duração',
'table.actions': 'Ações',
'table.date': 'Data',
'table.type': 'Tipo',
'table.directory': 'Diretório',
'table.name': 'Nome',
'profiles.title': 'Backup Profiles',
'profiles.create': '+ Criar Profile',
'profiles.reload': 'Recarregar',
'profiles.empty': 'Nenhum profile salvo.',
'profiles.newProfile': 'Novo Profile',
'profiles.editProfile': 'Editar Profile',
'profiles.name': 'Nome do profile',
'profiles.namePlaceholder': 'Backup banco principal',
'profiles.storageLocation': 'Local de armazenamento',
'profiles.storageLocationPlaceholder': 'Selecione um local...',
'profiles.backupScope': 'Escopo do backup',
'profiles.scopeVolumes': 'Somente volumes',
'profiles.scopeVolumesDesc': 'mantém o comportamento atual de backup de volumes e binds',
'profiles.scopeContainer': 'Container inteiro',
'profiles.scopeContainerDesc': 'gera um único tar por container a partir de /',
'profiles.containers': 'Containers',
'profiles.refreshContainers': 'Atualizar lista',
'profiles.save': 'Salvar profile',
'profiles.cancel': 'Cancelar',
'profiles.saved': 'Profile salvo.',
'profiles.deleted': 'Profile removido.',
'profiles.confirmDelete': 'Excluir o profile',
'action.run': 'Run',
'action.running': 'Executando...',
'action.edit': 'Editar',
'action.delete': 'Excluir',
'action.restore': 'Restore',
'action.restoring': 'Restaurando...',
'action.refresh': 'Refresh',
'action.save': 'Salvar',
'action.cancel': 'Cancelar',
'action.close': 'Fechar',
'action.confirm': 'Confirmar',
'action.selectAll': 'Marcar todos',
'mode.full': 'Full',
'mode.incremental': 'Incremental',
'mode.backupMode': 'Modo do backup',
'runs.title': 'Backup Runs',
'runs.allRuns': 'Todas as execuções',
'runs.empty': 'Nenhum run encontrado.',
'backups.title': 'Backups',
'backups.noBackups': 'Nenhum backup realizado.',
'backups.noProfiles': 'Nenhum profile encontrado.',
'storage.title': 'Storage Locations',
'storage.new': '+ Novo Local',
'storage.empty': 'Nenhum local de armazenamento configurado. Crie um para poder configurar backup profiles.',
'storage.newLocation': 'Novo Local de Armazenamento',
'storage.name': 'Nome',
'storage.namePlaceholder': 'Backup principal',
'storage.directory': 'Diretório',
'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'Local de armazenamento salvo.',
'storage.deleted': 'Local removido.',
'storage.confirmDelete': 'Excluir este local de armazenamento?',
'settings.title': 'Configurações',
'settings.language': 'Idioma',
'settings.languageDesc': 'Selecione o idioma de toda a interface',
'settings.auth': 'Controle de Acesso',
'settings.authEnabled': 'Exigir usuário e senha para acesso',
'settings.username': 'Usuário',
'settings.password': 'Senha (deixe em branco para não alterar)',
'settings.saveSettings': 'Salvar configurações',
'settings.saved': 'Configurações salvas.',
'about.title': 'Sobre',
'about.description': 'Aplicação web para backup e restauração de volumes Docker com suporte a snapshots incrementais e restore seletivo.',
'about.currentVersion': 'Versão atual',
'about.latestVersion': 'Última versão',
'about.checking': 'Verificando...',
'about.upToDate': 'Atualizado',
'about.updateAvailable': 'Atualização disponível',
'about.update': 'Atualizar agora',
'about.updating': 'Atualizando...',
'about.changelog': 'Últimas alterações',
'about.checkError': 'Não foi possível verificar a versão mais recente.',
'about.updateSuccess': 'Atualização concluída. Reiniciando...',
'about.updateError': 'Falha na atualização.',
'login.title': 'Acesso restrito',
'login.subtitle': 'Faça login para continuar',
'login.username': 'Usuário',
'login.password': 'Senha',
'login.submit': 'Entrar',
'login.error': 'Usuário ou senha incorretos.',
'login.logout': 'Sair',
'progress.backup': 'Progresso do backup',
'progress.restore': 'Progresso do restore',
'progress.containers': 'Containers',
'progress.completed': 'concluído(s)',
'progress.remaining': 'Faltam',
'progress.current': 'Container atual',
'progress.files': 'Arquivos',
'progress.detailedLog': 'Log detalhado',
'progress.events': 'evento(s)',
'progress.noEvents': 'Nenhum evento detalhado ainda.',
'progress.step': 'Etapa',
'progress.waiting': 'Aguardando processamento de arquivo...',
'error.selectStorage': 'Selecione um local de armazenamento.',
'error.noFullBackup': 'Não há backup full disponível. Execute um backup full primeiro.',
'error.selectFullBackup': 'Selecione um backup full.',
'error.selectFullBackupModal': 'Selecionar backup full base',
'error.selectFullBackupDesc': 'Selecione o backup full que será utilizado como base para o backup incremental:',
'error.selectVolume': 'Selecione ao menos um volume.',
'error.selectContainer': 'Selecione ao menos um container para restaurar.',
'volume.title': 'Selecionar volumes para backup',
'volume.confirm': 'Confirmar seleção',
'restore.title': 'Selecionar containers para restore',
'restore.confirm': 'Restaurar selecionados',
'restore.confirmPrompt': 'Restaurar o backup selecionado para o profile',
'scope.volumes': 'somente volumes',
'scope.container': 'container inteiro',
'status.completed': 'Completed',
'status.partial': 'Partial',
'status.error': 'Error',
'status.running': 'Running',
};
const en = {
'nav.dashboard': 'Dashboard',
'nav.storage': 'Storage Locations',
'nav.profiles': 'Backup Profiles',
'nav.runs': 'Backup Runs',
'nav.backups': 'Backups',
'nav.settings': 'Settings',
'nav.about': 'About',
'dashboard.title': 'Dashboard',
'dashboard.totalContainers': 'Total Containers',
'dashboard.activeConnections': 'Active connections',
'dashboard.backupProfiles': 'Backup Profiles',
'dashboard.configuredProfiles': 'Configured profiles',
'dashboard.successful': 'Successful',
'dashboard.totalSuccessful': 'Total successful',
'dashboard.failed': 'Failed',
'dashboard.totalFailed': 'Total failed',
'dashboard.recentRuns': 'Recent Backup Runs',
'dashboard.createProfile': '+ Create Profile',
'table.id': 'ID', 'table.profile': 'Profile', 'table.mode': 'Mode', 'table.status': 'Status',
'table.containers': 'Containers', 'table.files': 'Files', 'table.size': 'Size',
'table.started': 'Started', 'table.duration': 'Duration', 'table.actions': 'Actions',
'table.date': 'Date', 'table.type': 'Type', 'table.directory': 'Directory', 'table.name': 'Name',
'profiles.title': 'Backup Profiles', 'profiles.create': '+ Create Profile', 'profiles.reload': 'Reload',
'profiles.empty': 'No saved profiles.', 'profiles.newProfile': 'New Profile', 'profiles.editProfile': 'Edit Profile',
'profiles.name': 'Profile name', 'profiles.namePlaceholder': 'Main database backup',
'profiles.storageLocation': 'Storage location', 'profiles.storageLocationPlaceholder': 'Select a location...',
'profiles.backupScope': 'Backup scope', 'profiles.scopeVolumes': 'Volumes only',
'profiles.scopeVolumesDesc': 'backs up volumes and bind mounts', 'profiles.scopeContainer': 'Entire container',
'profiles.scopeContainerDesc': 'creates a single tar per container from /',
'profiles.containers': 'Containers', 'profiles.refreshContainers': 'Refresh list',
'profiles.save': 'Save profile', 'profiles.cancel': 'Cancel', 'profiles.saved': 'Profile saved.',
'profiles.deleted': 'Profile removed.', 'profiles.confirmDelete': 'Delete profile',
'action.run': 'Run', 'action.running': 'Running...', 'action.edit': 'Edit', 'action.delete': 'Delete',
'action.restore': 'Restore', 'action.restoring': 'Restoring...', 'action.refresh': 'Refresh',
'action.save': 'Save', 'action.cancel': 'Cancel', 'action.close': 'Close',
'action.confirm': 'Confirm', 'action.selectAll': 'Select all',
'mode.full': 'Full', 'mode.incremental': 'Incremental', 'mode.backupMode': 'Backup mode',
'runs.title': 'Backup Runs', 'runs.allRuns': 'All Runs', 'runs.empty': 'No runs found.',
'backups.title': 'Backups', 'backups.noBackups': 'No backups performed.', 'backups.noProfiles': 'No profiles found.',
'storage.title': 'Storage Locations', 'storage.new': '+ New Location',
'storage.empty': 'No storage locations configured. Create one to set up backup profiles.',
'storage.newLocation': 'New Storage Location', 'storage.name': 'Name', 'storage.namePlaceholder': 'Main backup',
'storage.directory': 'Directory', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'Storage location saved.', 'storage.deleted': 'Location removed.',
'storage.confirmDelete': 'Delete this storage location?',
'settings.title': 'Settings', 'settings.language': 'Language',
'settings.languageDesc': 'Select the interface language', 'settings.auth': 'Access Control',
'settings.authEnabled': 'Require username and password for access',
'settings.username': 'Username', 'settings.password': 'Password (leave blank to keep current)',
'settings.saveSettings': 'Save settings', 'settings.saved': 'Settings saved.',
'about.title': 'About',
'about.description': 'Web application for Docker volume backup and restore with support for incremental snapshots and selective restore.',
'about.currentVersion': 'Current version', 'about.latestVersion': 'Latest version',
'about.checking': 'Checking...', 'about.upToDate': 'Up to date',
'about.updateAvailable': 'Update available', 'about.update': 'Update now',
'about.updating': 'Updating...', 'about.changelog': 'Latest changes',
'about.checkError': 'Could not check for latest version.',
'about.updateSuccess': 'Update complete. Restarting...', 'about.updateError': 'Update failed.',
'login.title': 'Restricted access', 'login.subtitle': 'Please log in to continue',
'login.username': 'Username', 'login.password': 'Password', 'login.submit': 'Sign in',
'login.error': 'Incorrect username or password.', 'login.logout': 'Log out',
'progress.backup': 'Backup progress', 'progress.restore': 'Restore progress',
'progress.containers': 'Containers', 'progress.completed': 'completed',
'progress.remaining': 'Remaining', 'progress.current': 'Current container',
'progress.files': 'Files', 'progress.detailedLog': 'Detailed log',
'progress.events': 'event(s)', 'progress.noEvents': 'No detailed events yet.',
'progress.step': 'Step', 'progress.waiting': 'Waiting for file processing...',
'error.selectStorage': 'Select a storage location.',
'error.noFullBackup': 'No full backup available. Run a full backup first.',
'error.selectFullBackup': 'Select a full backup.',
'error.selectFullBackupModal': 'Select base full backup',
'error.selectFullBackupDesc': 'Select the full backup to use as base for the incremental backup:',
'error.selectVolume': 'Select at least one volume.',
'error.selectContainer': 'Select at least one container to restore.',
'volume.title': 'Select volumes for backup', 'volume.confirm': 'Confirm selection',
'restore.title': 'Select containers for restore', 'restore.confirm': 'Restore selected',
'restore.confirmPrompt': 'Restore the selected backup for profile',
'scope.volumes': 'volumes only', 'scope.container': 'entire container',
'status.completed': 'Completed', 'status.partial': 'Partial', 'status.error': 'Error', 'status.running': 'Running',
};
const es = {
'nav.dashboard': 'Panel de control', 'nav.storage': 'Ubicaciones de almacenamiento',
'nav.profiles': 'Perfiles de copia de seguridad', 'nav.runs': 'Ejecuciones de copia',
'nav.backups': 'Copias de seguridad', 'nav.settings': 'Configuración', 'nav.about': 'Acerca de',
'dashboard.title': 'Panel de control', 'dashboard.totalContainers': 'Total de contenedores',
'dashboard.activeConnections': 'Conexiones activas', 'dashboard.backupProfiles': 'Perfiles de copia',
'dashboard.configuredProfiles': 'Perfiles configurados', 'dashboard.successful': 'Exitosas',
'dashboard.totalSuccessful': 'Total exitosas', 'dashboard.failed': 'Fallidas',
'dashboard.totalFailed': 'Total fallidas', 'dashboard.recentRuns': 'Ejecuciones recientes',
'dashboard.createProfile': '+ Crear perfil',
'table.id': 'ID', 'table.profile': 'Perfil', 'table.mode': 'Modo', 'table.status': 'Estado',
'table.containers': 'Contenedores', 'table.files': 'Archivos', 'table.size': 'Tamaño',
'table.started': 'Iniciado', 'table.duration': 'Duración', 'table.actions': 'Acciones',
'table.date': 'Fecha', 'table.type': 'Tipo', 'table.directory': 'Directorio', 'table.name': 'Nombre',
'profiles.title': 'Perfiles de copia de seguridad', 'profiles.create': '+ Crear perfil',
'profiles.reload': 'Recargar', 'profiles.empty': 'No hay perfiles guardados.',
'profiles.newProfile': 'Nuevo perfil', 'profiles.editProfile': 'Editar perfil',
'profiles.name': 'Nombre del perfil', 'profiles.namePlaceholder': 'Copia de seguridad principal',
'profiles.storageLocation': 'Ubicación de almacenamiento', 'profiles.storageLocationPlaceholder': 'Seleccione una ubicación...',
'profiles.backupScope': 'Alcance de la copia', 'profiles.scopeVolumes': 'Solo volúmenes',
'profiles.scopeVolumesDesc': 'realiza copia de seguridad de volúmenes y montajes',
'profiles.scopeContainer': 'Contenedor completo', 'profiles.scopeContainerDesc': 'crea un único tar por contenedor desde /',
'profiles.containers': 'Contenedores', 'profiles.refreshContainers': 'Actualizar lista',
'profiles.save': 'Guardar perfil', 'profiles.cancel': 'Cancelar', 'profiles.saved': 'Perfil guardado.',
'profiles.deleted': 'Perfil eliminado.', 'profiles.confirmDelete': 'Eliminar el perfil',
'action.run': 'Ejecutar', 'action.running': 'Ejecutando...', 'action.edit': 'Editar', 'action.delete': 'Eliminar',
'action.restore': 'Restaurar', 'action.restoring': 'Restaurando...', 'action.refresh': 'Actualizar',
'action.save': 'Guardar', 'action.cancel': 'Cancelar', 'action.close': 'Cerrar',
'action.confirm': 'Confirmar', 'action.selectAll': 'Seleccionar todo',
'mode.full': 'Completo', 'mode.incremental': 'Incremental', 'mode.backupMode': 'Modo de copia',
'runs.title': 'Ejecuciones de copia', 'runs.allRuns': 'Todas las ejecuciones', 'runs.empty': 'No se encontraron ejecuciones.',
'backups.title': 'Copias de seguridad', 'backups.noBackups': 'No se han realizado copias.', 'backups.noProfiles': 'No se encontraron perfiles.',
'storage.title': 'Ubicaciones de almacenamiento', 'storage.new': '+ Nueva ubicación',
'storage.empty': 'No hay ubicaciones de almacenamiento. Cree una para configurar perfiles.',
'storage.newLocation': 'Nueva ubicación de almacenamiento', 'storage.name': 'Nombre', 'storage.namePlaceholder': 'Copia principal',
'storage.directory': 'Directorio', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'Ubicación guardada.', 'storage.deleted': 'Ubicación eliminada.',
'storage.confirmDelete': '¿Eliminar esta ubicación de almacenamiento?',
'settings.title': 'Configuración', 'settings.language': 'Idioma',
'settings.languageDesc': 'Seleccione el idioma de la interfaz', 'settings.auth': 'Control de acceso',
'settings.authEnabled': 'Requerir usuario y contraseña para acceder',
'settings.username': 'Usuario', 'settings.password': 'Contraseña (dejar en blanco para no cambiar)',
'settings.saveSettings': 'Guardar configuración', 'settings.saved': 'Configuración guardada.',
'about.title': 'Acerca de',
'about.description': 'Aplicación web para copia de seguridad y restauración de volúmenes Docker con soporte para snapshots incrementales y restauración selectiva.',
'about.currentVersion': 'Versión actual', 'about.latestVersion': 'Última versión',
'about.checking': 'Verificando...', 'about.upToDate': 'Actualizado',
'about.updateAvailable': 'Actualización disponible', 'about.update': 'Actualizar ahora',
'about.updating': 'Actualizando...', 'about.changelog': 'Últimos cambios',
'about.checkError': 'No se pudo verificar la versión más reciente.',
'about.updateSuccess': 'Actualización completa. Reiniciando...', 'about.updateError': 'Error en la actualización.',
'login.title': 'Acceso restringido', 'login.subtitle': 'Inicie sesión para continuar',
'login.username': 'Usuario', 'login.password': 'Contraseña', 'login.submit': 'Iniciar sesión',
'login.error': 'Usuario o contraseña incorrectos.', 'login.logout': 'Cerrar sesión',
'progress.backup': 'Progreso de la copia', 'progress.restore': 'Progreso de la restauración',
'progress.containers': 'Contenedores', 'progress.completed': 'completado(s)',
'progress.remaining': 'Restantes', 'progress.current': 'Contenedor actual',
'progress.files': 'Archivos', 'progress.detailedLog': 'Registro detallado',
'progress.events': 'evento(s)', 'progress.noEvents': 'No hay eventos detallados aún.',
'progress.step': 'Paso', 'progress.waiting': 'Esperando el procesamiento de archivos...',
'error.selectStorage': 'Seleccione una ubicación de almacenamiento.',
'error.noFullBackup': 'No hay copia completa disponible. Ejecute una primero.',
'error.selectFullBackup': 'Seleccione una copia completa.',
'error.selectFullBackupModal': 'Seleccionar copia base', 'error.selectFullBackupDesc': 'Seleccione la copia completa que se usará como base:',
'error.selectVolume': 'Seleccione al menos un volumen.', 'error.selectContainer': 'Seleccione al menos un contenedor.',
'volume.title': 'Seleccionar volúmenes para copia', 'volume.confirm': 'Confirmar selección',
'restore.title': 'Seleccionar contenedores para restaurar', 'restore.confirm': 'Restaurar seleccionados',
'restore.confirmPrompt': 'Restaurar la copia seleccionada para el perfil',
'scope.volumes': 'solo volúmenes', 'scope.container': 'contenedor completo',
'status.completed': 'Completado', 'status.partial': 'Parcial', 'status.error': 'Error', 'status.running': 'Ejecutando',
};
const de = {
'nav.dashboard': 'Dashboard', 'nav.storage': 'Speicherorte', 'nav.profiles': 'Backup-Profile',
'nav.runs': 'Backup-Läufe', 'nav.backups': 'Sicherungen', 'nav.settings': 'Einstellungen', 'nav.about': 'Über die App',
'dashboard.title': 'Dashboard', 'dashboard.totalContainers': 'Container gesamt',
'dashboard.activeConnections': 'Aktive Verbindungen', 'dashboard.backupProfiles': 'Backup-Profile',
'dashboard.configuredProfiles': 'Konfigurierte Profile', 'dashboard.successful': 'Erfolgreich',
'dashboard.totalSuccessful': 'Gesamt erfolgreich', 'dashboard.failed': 'Fehlgeschlagen',
'dashboard.totalFailed': 'Gesamt fehlgeschlagen', 'dashboard.recentRuns': 'Letzte Backup-Läufe',
'dashboard.createProfile': '+ Profil erstellen',
'table.id': 'ID', 'table.profile': 'Profil', 'table.mode': 'Modus', 'table.status': 'Status',
'table.containers': 'Container', 'table.files': 'Dateien', 'table.size': 'Größe',
'table.started': 'Gestartet', 'table.duration': 'Dauer', 'table.actions': 'Aktionen',
'table.date': 'Datum', 'table.type': 'Typ', 'table.directory': 'Verzeichnis', 'table.name': 'Name',
'profiles.title': 'Backup-Profile', 'profiles.create': '+ Profil erstellen', 'profiles.reload': 'Neu laden',
'profiles.empty': 'Keine Profile gespeichert.', 'profiles.newProfile': 'Neues Profil', 'profiles.editProfile': 'Profil bearbeiten',
'profiles.name': 'Profilname', 'profiles.namePlaceholder': 'Hauptdatenbank-Backup',
'profiles.storageLocation': 'Speicherort', 'profiles.storageLocationPlaceholder': 'Speicherort auswählen...',
'profiles.backupScope': 'Backup-Umfang', 'profiles.scopeVolumes': 'Nur Volumes',
'profiles.scopeVolumesDesc': 'sichert Volumes und Bind-Mounts', 'profiles.scopeContainer': 'Gesamter Container',
'profiles.scopeContainerDesc': 'erstellt ein einzelnes tar pro Container von /',
'profiles.containers': 'Container', 'profiles.refreshContainers': 'Liste aktualisieren',
'profiles.save': 'Profil speichern', 'profiles.cancel': 'Abbrechen', 'profiles.saved': 'Profil gespeichert.',
'profiles.deleted': 'Profil entfernt.', 'profiles.confirmDelete': 'Profil löschen',
'action.run': 'Ausführen', 'action.running': 'Wird ausgeführt...', 'action.edit': 'Bearbeiten', 'action.delete': 'Löschen',
'action.restore': 'Wiederherstellen', 'action.restoring': 'Wird wiederhergestellt...', 'action.refresh': 'Aktualisieren',
'action.save': 'Speichern', 'action.cancel': 'Abbrechen', 'action.close': 'Schließen',
'action.confirm': 'Bestätigen', 'action.selectAll': 'Alle auswählen',
'mode.full': 'Vollständig', 'mode.incremental': 'Inkrementell', 'mode.backupMode': 'Backup-Modus',
'runs.title': 'Backup-Läufe', 'runs.allRuns': 'Alle Läufe', 'runs.empty': 'Keine Läufe gefunden.',
'backups.title': 'Sicherungen', 'backups.noBackups': 'Keine Sicherungen durchgeführt.', 'backups.noProfiles': 'Keine Profile gefunden.',
'storage.title': 'Speicherorte', 'storage.new': '+ Neuer Speicherort',
'storage.empty': 'Keine Speicherorte konfiguriert. Erstellen Sie einen, um Backup-Profile einzurichten.',
'storage.newLocation': 'Neuer Speicherort', 'storage.name': 'Name', 'storage.namePlaceholder': 'Haupt-Backup',
'storage.directory': 'Verzeichnis', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'Speicherort gespeichert.', 'storage.deleted': 'Speicherort entfernt.',
'storage.confirmDelete': 'Diesen Speicherort löschen?',
'settings.title': 'Einstellungen', 'settings.language': 'Sprache',
'settings.languageDesc': 'Wählen Sie die Sprache der Benutzeroberfläche', 'settings.auth': 'Zugangskontrolle',
'settings.authEnabled': 'Benutzername und Passwort erforderlich',
'settings.username': 'Benutzername', 'settings.password': 'Passwort (leer lassen, um nicht zu ändern)',
'settings.saveSettings': 'Einstellungen speichern', 'settings.saved': 'Einstellungen gespeichert.',
'about.title': 'Über die App',
'about.description': 'Webanwendung für Docker-Volume-Backup und -Wiederherstellung mit Unterstützung für inkrementelle Snapshots.',
'about.currentVersion': 'Aktuelle Version', 'about.latestVersion': 'Neueste Version',
'about.checking': 'Wird überprüft...', 'about.upToDate': 'Aktuell',
'about.updateAvailable': 'Update verfügbar', 'about.update': 'Jetzt aktualisieren',
'about.updating': 'Wird aktualisiert...', 'about.changelog': 'Letzte Änderungen',
'about.checkError': 'Version konnte nicht überprüft werden.',
'about.updateSuccess': 'Update abgeschlossen. Neustart...', 'about.updateError': 'Update fehlgeschlagen.',
'login.title': 'Zugang eingeschränkt', 'login.subtitle': 'Bitte melden Sie sich an',
'login.username': 'Benutzername', 'login.password': 'Passwort', 'login.submit': 'Anmelden',
'login.error': 'Falscher Benutzername oder Passwort.', 'login.logout': 'Abmelden',
'progress.backup': 'Backup-Fortschritt', 'progress.restore': 'Wiederherstellungs-Fortschritt',
'progress.containers': 'Container', 'progress.completed': 'abgeschlossen',
'progress.remaining': 'Verbleibend', 'progress.current': 'Aktueller Container',
'progress.files': 'Dateien', 'progress.detailedLog': 'Detailliertes Protokoll',
'progress.events': 'Ereignis(se)', 'progress.noEvents': 'Noch keine detaillierten Ereignisse.',
'progress.step': 'Schritt', 'progress.waiting': 'Warte auf Dateiverarbeitung...',
'error.selectStorage': 'Wählen Sie einen Speicherort.',
'error.noFullBackup': 'Kein vollständiges Backup verfügbar. Führen Sie zuerst ein vollständiges Backup durch.',
'error.selectFullBackup': 'Wählen Sie ein vollständiges Backup.',
'error.selectFullBackupModal': 'Vollständiges Basis-Backup auswählen',
'error.selectFullBackupDesc': 'Wählen Sie das vollständige Backup als Basis für das inkrementelle Backup:',
'error.selectVolume': 'Wählen Sie mindestens ein Volume.', 'error.selectContainer': 'Wählen Sie mindestens einen Container.',
'volume.title': 'Volumes für Backup auswählen', 'volume.confirm': 'Auswahl bestätigen',
'restore.title': 'Container zum Wiederherstellen auswählen', 'restore.confirm': 'Ausgewählte wiederherstellen',
'restore.confirmPrompt': 'Das ausgewählte Backup für das Profil wiederherstellen',
'scope.volumes': 'nur Volumes', 'scope.container': 'gesamter Container',
'status.completed': 'Abgeschlossen', 'status.partial': 'Teilweise', 'status.error': 'Fehler', 'status.running': 'Läuft',
};
const pl = {
'nav.dashboard': 'Panel główny', 'nav.storage': 'Lokalizacje przechowywania',
'nav.profiles': 'Profile kopii zapasowych', 'nav.runs': 'Uruchomienia kopii',
'nav.backups': 'Kopie zapasowe', 'nav.settings': 'Ustawienia', 'nav.about': 'O aplikacji',
'dashboard.title': 'Panel główny', 'dashboard.totalContainers': 'Łączna liczba kontenerów',
'dashboard.activeConnections': 'Aktywne połączenia', 'dashboard.backupProfiles': 'Profile kopii zapasowych',
'dashboard.configuredProfiles': 'Skonfigurowane profile', 'dashboard.successful': 'Udane',
'dashboard.totalSuccessful': 'Łącznie udanych', 'dashboard.failed': 'Nieudane',
'dashboard.totalFailed': 'Łącznie nieudanych', 'dashboard.recentRuns': 'Ostatnie uruchomienia',
'dashboard.createProfile': '+ Utwórz profil',
'table.id': 'ID', 'table.profile': 'Profil', 'table.mode': 'Tryb', 'table.status': 'Status',
'table.containers': 'Kontenery', 'table.files': 'Pliki', 'table.size': 'Rozmiar',
'table.started': 'Rozpoczęto', 'table.duration': 'Czas trwania', 'table.actions': 'Akcje',
'table.date': 'Data', 'table.type': 'Typ', 'table.directory': 'Katalog', 'table.name': 'Nazwa',
'profiles.title': 'Profile kopii zapasowych', 'profiles.create': '+ Utwórz profil', 'profiles.reload': 'Odśwież',
'profiles.empty': 'Brak zapisanych profili.', 'profiles.newProfile': 'Nowy profil', 'profiles.editProfile': 'Edytuj profil',
'profiles.name': 'Nazwa profilu', 'profiles.namePlaceholder': 'Kopia głównej bazy danych',
'profiles.storageLocation': 'Lokalizacja przechowywania', 'profiles.storageLocationPlaceholder': 'Wybierz lokalizację...',
'profiles.backupScope': 'Zakres kopii zapasowej', 'profiles.scopeVolumes': 'Tylko woluminy',
'profiles.scopeVolumesDesc': 'tworzy kopie woluminów i montowań', 'profiles.scopeContainer': 'Cały kontener',
'profiles.scopeContainerDesc': 'tworzy jeden plik tar na kontener od /',
'profiles.containers': 'Kontenery', 'profiles.refreshContainers': 'Odśwież listę',
'profiles.save': 'Zapisz profil', 'profiles.cancel': 'Anuluj', 'profiles.saved': 'Profil zapisany.',
'profiles.deleted': 'Profil usunięty.', 'profiles.confirmDelete': 'Usuń profil',
'action.run': 'Uruchom', 'action.running': 'Uruchamianie...', 'action.edit': 'Edytuj', 'action.delete': 'Usuń',
'action.restore': 'Przywróć', 'action.restoring': 'Przywracanie...', 'action.refresh': 'Odśwież',
'action.save': 'Zapisz', 'action.cancel': 'Anuluj', 'action.close': 'Zamknij',
'action.confirm': 'Potwierdź', 'action.selectAll': 'Zaznacz wszystko',
'mode.full': 'Pełna', 'mode.incremental': 'Przyrostowa', 'mode.backupMode': 'Tryb kopii zapasowej',
'runs.title': 'Uruchomienia kopii', 'runs.allRuns': 'Wszystkie uruchomienia', 'runs.empty': 'Nie znaleziono uruchomień.',
'backups.title': 'Kopie zapasowe', 'backups.noBackups': 'Nie wykonano żadnych kopii.', 'backups.noProfiles': 'Nie znaleziono profili.',
'storage.title': 'Lokalizacje przechowywania', 'storage.new': '+ Nowa lokalizacja',
'storage.empty': 'Nie skonfigurowano lokalizacji przechowywania. Utwórz jedną, aby skonfigurować profile.',
'storage.newLocation': 'Nowa lokalizacja przechowywania', 'storage.name': 'Nazwa', 'storage.namePlaceholder': 'Główna kopia',
'storage.directory': 'Katalog', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'Lokalizacja zapisana.', 'storage.deleted': 'Lokalizacja usunięta.',
'storage.confirmDelete': 'Usunąć tę lokalizację przechowywania?',
'settings.title': 'Ustawienia', 'settings.language': 'Język',
'settings.languageDesc': 'Wybierz język interfejsu', 'settings.auth': 'Kontrola dostępu',
'settings.authEnabled': 'Wymagaj nazwy użytkownika i hasła',
'settings.username': 'Użytkownik', 'settings.password': 'Hasło (zostaw puste, aby nie zmieniać)',
'settings.saveSettings': 'Zapisz ustawienia', 'settings.saved': 'Ustawienia zapisane.',
'about.title': 'O aplikacji',
'about.description': 'Aplikacja internetowa do tworzenia kopii zapasowych i przywracania woluminów Docker z obsługą migawek przyrostowych.',
'about.currentVersion': 'Bieżąca wersja', 'about.latestVersion': 'Najnowsza wersja',
'about.checking': 'Sprawdzanie...', 'about.upToDate': 'Aktualna',
'about.updateAvailable': 'Dostępna aktualizacja', 'about.update': 'Aktualizuj teraz',
'about.updating': 'Aktualizowanie...', 'about.changelog': 'Ostatnie zmiany',
'about.checkError': 'Nie można sprawdzić najnowszej wersji.',
'about.updateSuccess': 'Aktualizacja zakończona. Restartuję...', 'about.updateError': 'Aktualizacja nie powiodła się.',
'login.title': 'Dostęp ograniczony', 'login.subtitle': 'Zaloguj się, aby kontynuować',
'login.username': 'Użytkownik', 'login.password': 'Hasło', 'login.submit': 'Zaloguj się',
'login.error': 'Nieprawidłowy użytkownik lub hasło.', 'login.logout': 'Wyloguj się',
'progress.backup': 'Postęp kopii zapasowej', 'progress.restore': 'Postęp przywracania',
'progress.containers': 'Kontenery', 'progress.completed': 'ukończono',
'progress.remaining': 'Pozostało', 'progress.current': 'Bieżący kontener',
'progress.files': 'Pliki', 'progress.detailedLog': 'Szczegółowy dziennik',
'progress.events': 'zdarzenie(a)', 'progress.noEvents': 'Brak szczegółowych zdarzeń.',
'progress.step': 'Krok', 'progress.waiting': 'Oczekiwanie na przetwarzanie pliku...',
'error.selectStorage': 'Wybierz lokalizację przechowywania.',
'error.noFullBackup': 'Brak pełnej kopii zapasowej. Najpierw wykonaj pełną kopię.',
'error.selectFullBackup': 'Wybierz pełną kopię zapasową.',
'error.selectFullBackupModal': 'Wybierz bazową pełną kopię',
'error.selectFullBackupDesc': 'Wybierz pełną kopię jako bazę dla przyrostowej kopii:',
'error.selectVolume': 'Wybierz co najmniej jeden wolumen.', 'error.selectContainer': 'Wybierz co najmniej jeden kontener.',
'volume.title': 'Wybierz woluminy do kopii', 'volume.confirm': 'Potwierdź wybór',
'restore.title': 'Wybierz kontenery do przywrócenia', 'restore.confirm': 'Przywróć wybrane',
'restore.confirmPrompt': 'Przywróć wybraną kopię dla profilu',
'scope.volumes': 'tylko woluminy', 'scope.container': 'cały kontener',
'status.completed': 'Zakończono', 'status.partial': 'Częściowe', 'status.error': 'Błąd', 'status.running': 'Uruchomione',
};
const it = {
'nav.dashboard': 'Dashboard', 'nav.storage': 'Percorsi di archiviazione',
'nav.profiles': 'Profili di backup', 'nav.runs': 'Esecuzioni backup',
'nav.backups': 'Backup', 'nav.settings': 'Impostazioni', 'nav.about': 'Informazioni',
'dashboard.title': 'Dashboard', 'dashboard.totalContainers': 'Container totali',
'dashboard.activeConnections': 'Connessioni attive', 'dashboard.backupProfiles': 'Profili di backup',
'dashboard.configuredProfiles': 'Profili configurati', 'dashboard.successful': 'Riusciti',
'dashboard.totalSuccessful': 'Totale riusciti', 'dashboard.failed': 'Falliti',
'dashboard.totalFailed': 'Totale falliti', 'dashboard.recentRuns': 'Esecuzioni recenti',
'dashboard.createProfile': '+ Crea profilo',
'table.id': 'ID', 'table.profile': 'Profilo', 'table.mode': 'Modalità', 'table.status': 'Stato',
'table.containers': 'Container', 'table.files': 'File', 'table.size': 'Dimensione',
'table.started': 'Avviato', 'table.duration': 'Durata', 'table.actions': 'Azioni',
'table.date': 'Data', 'table.type': 'Tipo', 'table.directory': 'Directory', 'table.name': 'Nome',
'profiles.title': 'Profili di backup', 'profiles.create': '+ Crea profilo', 'profiles.reload': 'Ricarica',
'profiles.empty': 'Nessun profilo salvato.', 'profiles.newProfile': 'Nuovo profilo', 'profiles.editProfile': 'Modifica profilo',
'profiles.name': 'Nome del profilo', 'profiles.namePlaceholder': 'Backup database principale',
'profiles.storageLocation': 'Percorso di archiviazione', 'profiles.storageLocationPlaceholder': 'Seleziona un percorso...',
'profiles.backupScope': 'Ambito del backup', 'profiles.scopeVolumes': 'Solo volumi',
'profiles.scopeVolumesDesc': 'esegue il backup di volumi e mount bind', 'profiles.scopeContainer': 'Container completo',
'profiles.scopeContainerDesc': 'crea un singolo tar per container da /',
'profiles.containers': 'Container', 'profiles.refreshContainers': 'Aggiorna lista',
'profiles.save': 'Salva profilo', 'profiles.cancel': 'Annulla', 'profiles.saved': 'Profilo salvato.',
'profiles.deleted': 'Profilo rimosso.', 'profiles.confirmDelete': 'Elimina il profilo',
'action.run': 'Esegui', 'action.running': 'In esecuzione...', 'action.edit': 'Modifica', 'action.delete': 'Elimina',
'action.restore': 'Ripristina', 'action.restoring': 'Ripristino...', 'action.refresh': 'Aggiorna',
'action.save': 'Salva', 'action.cancel': 'Annulla', 'action.close': 'Chiudi',
'action.confirm': 'Conferma', 'action.selectAll': 'Seleziona tutto',
'mode.full': 'Completo', 'mode.incremental': 'Incrementale', 'mode.backupMode': 'Modalità backup',
'runs.title': 'Esecuzioni backup', 'runs.allRuns': 'Tutte le esecuzioni', 'runs.empty': 'Nessuna esecuzione trovata.',
'backups.title': 'Backup', 'backups.noBackups': 'Nessun backup eseguito.', 'backups.noProfiles': 'Nessun profilo trovato.',
'storage.title': 'Percorsi di archiviazione', 'storage.new': '+ Nuovo percorso',
'storage.empty': 'Nessun percorso di archiviazione configurato. Creane uno per configurare i profili.',
'storage.newLocation': 'Nuovo percorso di archiviazione', 'storage.name': 'Nome', 'storage.namePlaceholder': 'Backup principale',
'storage.directory': 'Directory', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'Percorso salvato.', 'storage.deleted': 'Percorso rimosso.',
'storage.confirmDelete': 'Eliminare questo percorso di archiviazione?',
'settings.title': 'Impostazioni', 'settings.language': 'Lingua',
'settings.languageDesc': "Seleziona la lingua dell'interfaccia", 'settings.auth': 'Controllo accessi',
'settings.authEnabled': 'Richiedi nome utente e password per accedere',
'settings.username': 'Utente', 'settings.password': 'Password (lascia vuoto per non modificare)',
'settings.saveSettings': 'Salva impostazioni', 'settings.saved': 'Impostazioni salvate.',
'about.title': 'Informazioni',
'about.description': "Applicazione web per il backup e il ripristino di volumi Docker con supporto per snapshot incrementali e ripristino selettivo.",
'about.currentVersion': 'Versione corrente', 'about.latestVersion': 'Ultima versione',
'about.checking': 'Verifica in corso...', 'about.upToDate': 'Aggiornato',
'about.updateAvailable': 'Aggiornamento disponibile', 'about.update': 'Aggiorna ora',
'about.updating': 'Aggiornamento...', 'about.changelog': 'Ultime modifiche',
'about.checkError': 'Impossibile verificare la versione più recente.',
'about.updateSuccess': 'Aggiornamento completato. Riavvio...', 'about.updateError': "Aggiornamento non riuscito.",
'login.title': 'Accesso limitato', 'login.subtitle': 'Effettua il login per continuare',
'login.username': 'Utente', 'login.password': 'Password', 'login.submit': 'Accedi',
'login.error': 'Nome utente o password errati.', 'login.logout': 'Disconnetti',
'progress.backup': 'Avanzamento backup', 'progress.restore': 'Avanzamento ripristino',
'progress.containers': 'Container', 'progress.completed': 'completato/i',
'progress.remaining': 'Rimanenti', 'progress.current': 'Container corrente',
'progress.files': 'File', 'progress.detailedLog': 'Log dettagliato',
'progress.events': 'evento/i', 'progress.noEvents': 'Nessun evento dettagliato ancora.',
'progress.step': 'Fase', 'progress.waiting': 'In attesa di elaborazione file...',
'error.selectStorage': 'Seleziona un percorso di archiviazione.',
'error.noFullBackup': 'Nessun backup completo disponibile. Esegui prima un backup completo.',
'error.selectFullBackup': 'Seleziona un backup completo.',
'error.selectFullBackupModal': 'Seleziona backup completo base',
'error.selectFullBackupDesc': 'Seleziona il backup completo da usare come base per il backup incrementale:',
'error.selectVolume': 'Seleziona almeno un volume.', 'error.selectContainer': 'Seleziona almeno un container.',
'volume.title': 'Seleziona volumi per il backup', 'volume.confirm': 'Conferma selezione',
'restore.title': 'Seleziona container per il ripristino', 'restore.confirm': 'Ripristina selezionati',
'restore.confirmPrompt': 'Ripristina il backup selezionato per il profilo',
'scope.volumes': 'solo volumi', 'scope.container': 'container completo',
'status.completed': 'Completato', 'status.partial': 'Parziale', 'status.error': 'Errore', 'status.running': 'In esecuzione',
};
const ru = {
'nav.dashboard': 'Панель управления', 'nav.storage': 'Места хранения',
'nav.profiles': 'Профили резервного копирования', 'nav.runs': 'Запуски резервного копирования',
'nav.backups': 'Резервные копии', 'nav.settings': 'Настройки', 'nav.about': 'О приложении',
'dashboard.title': 'Панель управления', 'dashboard.totalContainers': 'Всего контейнеров',
'dashboard.activeConnections': 'Активные подключения', 'dashboard.backupProfiles': 'Профили резервного копирования',
'dashboard.configuredProfiles': 'Настроенные профили', 'dashboard.successful': 'Успешные',
'dashboard.totalSuccessful': 'Всего успешных', 'dashboard.failed': 'Неудачные',
'dashboard.totalFailed': 'Всего неудачных', 'dashboard.recentRuns': 'Последние запуски',
'dashboard.createProfile': '+ Создать профиль',
'table.id': 'ID', 'table.profile': 'Профиль', 'table.mode': 'Режим', 'table.status': 'Статус',
'table.containers': 'Контейнеры', 'table.files': 'Файлы', 'table.size': 'Размер',
'table.started': 'Начато', 'table.duration': 'Продолжительность', 'table.actions': 'Действия',
'table.date': 'Дата', 'table.type': 'Тип', 'table.directory': 'Директория', 'table.name': 'Название',
'profiles.title': 'Профили резервного копирования', 'profiles.create': '+ Создать профиль', 'profiles.reload': 'Обновить',
'profiles.empty': 'Нет сохранённых профилей.', 'profiles.newProfile': 'Новый профиль', 'profiles.editProfile': 'Редактировать профиль',
'profiles.name': 'Название профиля', 'profiles.namePlaceholder': 'Резервная копия основной БД',
'profiles.storageLocation': 'Место хранения', 'profiles.storageLocationPlaceholder': 'Выберите место...',
'profiles.backupScope': 'Область резервного копирования', 'profiles.scopeVolumes': 'Только тома',
'profiles.scopeVolumesDesc': 'создаёт резервные копии томов и точек монтирования', 'profiles.scopeContainer': 'Весь контейнер',
'profiles.scopeContainerDesc': 'создаёт один tar на контейнер начиная с /',
'profiles.containers': 'Контейнеры', 'profiles.refreshContainers': 'Обновить список',
'profiles.save': 'Сохранить профиль', 'profiles.cancel': 'Отмена', 'profiles.saved': 'Профиль сохранён.',
'profiles.deleted': 'Профиль удалён.', 'profiles.confirmDelete': 'Удалить профиль',
'action.run': 'Запустить', 'action.running': 'Выполняется...', 'action.edit': 'Редактировать', 'action.delete': 'Удалить',
'action.restore': 'Восстановить', 'action.restoring': 'Восстановление...', 'action.refresh': 'Обновить',
'action.save': 'Сохранить', 'action.cancel': 'Отмена', 'action.close': 'Закрыть',
'action.confirm': 'Подтвердить', 'action.selectAll': 'Выбрать все',
'mode.full': 'Полный', 'mode.incremental': 'Инкрементальный', 'mode.backupMode': 'Режим резервного копирования',
'runs.title': 'Запуски резервного копирования', 'runs.allRuns': 'Все запуски', 'runs.empty': 'Запусков не найдено.',
'backups.title': 'Резервные копии', 'backups.noBackups': 'Резервные копии не созданы.', 'backups.noProfiles': 'Профили не найдены.',
'storage.title': 'Места хранения', 'storage.new': '+ Новое место',
'storage.empty': 'Места хранения не настроены. Создайте одно для настройки профилей.',
'storage.newLocation': 'Новое место хранения', 'storage.name': 'Название', 'storage.namePlaceholder': 'Основная копия',
'storage.directory': 'Директория', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'Место хранения сохранено.', 'storage.deleted': 'Место хранения удалено.',
'storage.confirmDelete': 'Удалить это место хранения?',
'settings.title': 'Настройки', 'settings.language': 'Язык',
'settings.languageDesc': 'Выберите язык интерфейса', 'settings.auth': 'Контроль доступа',
'settings.authEnabled': 'Требовать имя пользователя и пароль для входа',
'settings.username': 'Пользователь', 'settings.password': 'Пароль (оставьте пустым, чтобы не менять)',
'settings.saveSettings': 'Сохранить настройки', 'settings.saved': 'Настройки сохранены.',
'about.title': 'О приложении',
'about.description': 'Веб-приложение для резервного копирования и восстановления томов Docker с поддержкой инкрементальных снапшотов и выборочного восстановления.',
'about.currentVersion': 'Текущая версия', 'about.latestVersion': 'Последняя версия',
'about.checking': 'Проверка...', 'about.upToDate': 'Актуальная',
'about.updateAvailable': 'Доступно обновление', 'about.update': 'Обновить сейчас',
'about.updating': 'Обновление...', 'about.changelog': 'Последние изменения',
'about.checkError': 'Не удалось проверить последнюю версию.',
'about.updateSuccess': 'Обновление завершено. Перезапуск...', 'about.updateError': 'Обновление не удалось.',
'login.title': 'Доступ ограничен', 'login.subtitle': 'Войдите, чтобы продолжить',
'login.username': 'Пользователь', 'login.password': 'Пароль', 'login.submit': 'Войти',
'login.error': 'Неверное имя пользователя или пароль.', 'login.logout': 'Выйти',
'progress.backup': 'Ход резервного копирования', 'progress.restore': 'Ход восстановления',
'progress.containers': 'Контейнеры', 'progress.completed': 'завершено',
'progress.remaining': 'Осталось', 'progress.current': 'Текущий контейнер',
'progress.files': 'Файлы', 'progress.detailedLog': 'Подробный журнал',
'progress.events': 'событий', 'progress.noEvents': 'Подробных событий пока нет.',
'progress.step': 'Шаг', 'progress.waiting': 'Ожидание обработки файла...',
'error.selectStorage': 'Выберите место хранения.',
'error.noFullBackup': 'Нет полной резервной копии. Сначала выполните полное резервное копирование.',
'error.selectFullBackup': 'Выберите полную резервную копию.',
'error.selectFullBackupModal': 'Выбор базовой полной копии',
'error.selectFullBackupDesc': 'Выберите полную резервную копию для использования в качестве базы:',
'error.selectVolume': 'Выберите хотя бы один том.', 'error.selectContainer': 'Выберите хотя бы один контейнер.',
'volume.title': 'Выбор томов для резервного копирования', 'volume.confirm': 'Подтвердить выбор',
'restore.title': 'Выбор контейнеров для восстановления', 'restore.confirm': 'Восстановить выбранные',
'restore.confirmPrompt': 'Восстановить выбранную копию для профиля',
'scope.volumes': 'только тома', 'scope.container': 'весь контейнер',
'status.completed': 'Завершено', 'status.partial': 'Частично', 'status.error': 'Ошибка', 'status.running': 'Выполняется',
};
const zh = {
'nav.dashboard': '仪表盘', 'nav.storage': '存储位置', 'nav.profiles': '备份配置',
'nav.runs': '备份运行', 'nav.backups': '备份记录', 'nav.settings': '设置', 'nav.about': '关于',
'dashboard.title': '仪表盘', 'dashboard.totalContainers': '容器总数',
'dashboard.activeConnections': '活动连接', 'dashboard.backupProfiles': '备份配置',
'dashboard.configuredProfiles': '已配置的配置文件', 'dashboard.successful': '成功',
'dashboard.totalSuccessful': '总成功数', 'dashboard.failed': '失败',
'dashboard.totalFailed': '总失败数', 'dashboard.recentRuns': '最近的备份运行',
'dashboard.createProfile': '+ 创建配置',
'table.id': 'ID', 'table.profile': '配置', 'table.mode': '模式', 'table.status': '状态',
'table.containers': '容器', 'table.files': '文件', 'table.size': '大小',
'table.started': '开始时间', 'table.duration': '持续时间', 'table.actions': '操作',
'table.date': '日期', 'table.type': '类型', 'table.directory': '目录', 'table.name': '名称',
'profiles.title': '备份配置', 'profiles.create': '+ 创建配置', 'profiles.reload': '刷新',
'profiles.empty': '没有保存的配置。', 'profiles.newProfile': '新建配置', 'profiles.editProfile': '编辑配置',
'profiles.name': '配置名称', 'profiles.namePlaceholder': '主数据库备份',
'profiles.storageLocation': '存储位置', 'profiles.storageLocationPlaceholder': '选择一个位置...',
'profiles.backupScope': '备份范围', 'profiles.scopeVolumes': '仅卷',
'profiles.scopeVolumesDesc': '备份卷和绑定挂载', 'profiles.scopeContainer': '整个容器',
'profiles.scopeContainerDesc': '为每个容器从/创建单个tar文件',
'profiles.containers': '容器', 'profiles.refreshContainers': '刷新列表',
'profiles.save': '保存配置', 'profiles.cancel': '取消', 'profiles.saved': '配置已保存。',
'profiles.deleted': '配置已删除。', 'profiles.confirmDelete': '删除配置',
'action.run': '运行', 'action.running': '运行中...', 'action.edit': '编辑', 'action.delete': '删除',
'action.restore': '恢复', 'action.restoring': '恢复中...', 'action.refresh': '刷新',
'action.save': '保存', 'action.cancel': '取消', 'action.close': '关闭',
'action.confirm': '确认', 'action.selectAll': '全选',
'mode.full': '完整', 'mode.incremental': '增量', 'mode.backupMode': '备份模式',
'runs.title': '备份运行', 'runs.allRuns': '所有运行', 'runs.empty': '未找到运行记录。',
'backups.title': '备份记录', 'backups.noBackups': '没有执行过备份。', 'backups.noProfiles': '未找到配置。',
'storage.title': '存储位置', 'storage.new': '+ 新建位置',
'storage.empty': '没有配置存储位置。创建一个以设置备份配置。',
'storage.newLocation': '新建存储位置', 'storage.name': '名称', 'storage.namePlaceholder': '主备份',
'storage.directory': '目录', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': '存储位置已保存。', 'storage.deleted': '位置已删除。',
'storage.confirmDelete': '删除此存储位置?',
'settings.title': '设置', 'settings.language': '语言',
'settings.languageDesc': '选择界面语言', 'settings.auth': '访问控制',
'settings.authEnabled': '访问需要用户名和密码',
'settings.username': '用户名', 'settings.password': '密码(留空表示不修改)',
'settings.saveSettings': '保存设置', 'settings.saved': '设置已保存。',
'about.title': '关于',
'about.description': '用于Docker卷备份和恢复的Web应用程序支持增量快照和选择性恢复。',
'about.currentVersion': '当前版本', 'about.latestVersion': '最新版本',
'about.checking': '检查中...', 'about.upToDate': '已是最新',
'about.updateAvailable': '有可用更新', 'about.update': '立即更新',
'about.updating': '更新中...', 'about.changelog': '最新更改',
'about.checkError': '无法检查最新版本。',
'about.updateSuccess': '更新完成。重启中...', 'about.updateError': '更新失败。',
'login.title': '访问受限', 'login.subtitle': '请登录以继续',
'login.username': '用户名', 'login.password': '密码', 'login.submit': '登录',
'login.error': '用户名或密码错误。', 'login.logout': '退出',
'progress.backup': '备份进度', 'progress.restore': '恢复进度',
'progress.containers': '容器', 'progress.completed': '已完成',
'progress.remaining': '剩余', 'progress.current': '当前容器',
'progress.files': '文件', 'progress.detailedLog': '详细日志',
'progress.events': '个事件', 'progress.noEvents': '暂无详细事件。',
'progress.step': '步骤', 'progress.waiting': '等待文件处理...',
'error.selectStorage': '请选择存储位置。',
'error.noFullBackup': '没有可用的完整备份。请先执行完整备份。',
'error.selectFullBackup': '请选择完整备份。',
'error.selectFullBackupModal': '选择基础完整备份',
'error.selectFullBackupDesc': '选择用作增量备份基础的完整备份:',
'error.selectVolume': '请至少选择一个卷。', 'error.selectContainer': '请至少选择一个容器。',
'volume.title': '选择备份卷', 'volume.confirm': '确认选择',
'restore.title': '选择要恢复的容器', 'restore.confirm': '恢复所选',
'restore.confirmPrompt': '恢复所选备份到配置',
'scope.volumes': '仅卷', 'scope.container': '整个容器',
'status.completed': '已完成', 'status.partial': '部分完成', 'status.error': '错误', 'status.running': '运行中',
};
const ja = {
'nav.dashboard': 'ダッシュボード', 'nav.storage': 'ストレージの場所',
'nav.profiles': 'バックアッププロファイル', 'nav.runs': 'バックアップ実行',
'nav.backups': 'バックアップ', 'nav.settings': '設定', 'nav.about': 'アプリについて',
'dashboard.title': 'ダッシュボード', 'dashboard.totalContainers': 'コンテナ合計',
'dashboard.activeConnections': 'アクティブな接続', 'dashboard.backupProfiles': 'バックアッププロファイル',
'dashboard.configuredProfiles': '設定済みプロファイル', 'dashboard.successful': '成功',
'dashboard.totalSuccessful': '合計成功数', 'dashboard.failed': '失敗',
'dashboard.totalFailed': '合計失敗数', 'dashboard.recentRuns': '最近のバックアップ実行',
'dashboard.createProfile': '+ プロファイル作成',
'table.id': 'ID', 'table.profile': 'プロファイル', 'table.mode': 'モード', 'table.status': 'ステータス',
'table.containers': 'コンテナ', 'table.files': 'ファイル', 'table.size': 'サイズ',
'table.started': '開始', 'table.duration': '所要時間', 'table.actions': '操作',
'table.date': '日付', 'table.type': '種類', 'table.directory': 'ディレクトリ', 'table.name': '名前',
'profiles.title': 'バックアッププロファイル', 'profiles.create': '+ プロファイル作成', 'profiles.reload': '更新',
'profiles.empty': '保存されたプロファイルがありません。', 'profiles.newProfile': '新しいプロファイル', 'profiles.editProfile': 'プロファイルを編集',
'profiles.name': 'プロファイル名', 'profiles.namePlaceholder': 'メインデータベースバックアップ',
'profiles.storageLocation': 'ストレージの場所', 'profiles.storageLocationPlaceholder': '場所を選択...',
'profiles.backupScope': 'バックアップ範囲', 'profiles.scopeVolumes': 'ボリュームのみ',
'profiles.scopeVolumesDesc': 'ボリュームとバインドマウントをバックアップ', 'profiles.scopeContainer': 'コンテナ全体',
'profiles.scopeContainerDesc': '各コンテナの/からtarを作成',
'profiles.containers': 'コンテナ', 'profiles.refreshContainers': 'リストを更新',
'profiles.save': 'プロファイルを保存', 'profiles.cancel': 'キャンセル', 'profiles.saved': 'プロファイルが保存されました。',
'profiles.deleted': 'プロファイルが削除されました。', 'profiles.confirmDelete': 'プロファイルを削除',
'action.run': '実行', 'action.running': '実行中...', 'action.edit': '編集', 'action.delete': '削除',
'action.restore': '復元', 'action.restoring': '復元中...', 'action.refresh': '更新',
'action.save': '保存', 'action.cancel': 'キャンセル', 'action.close': '閉じる',
'action.confirm': '確認', 'action.selectAll': 'すべて選択',
'mode.full': '完全', 'mode.incremental': '増分', 'mode.backupMode': 'バックアップモード',
'runs.title': 'バックアップ実行', 'runs.allRuns': 'すべての実行', 'runs.empty': '実行が見つかりません。',
'backups.title': 'バックアップ', 'backups.noBackups': 'バックアップは実行されていません。', 'backups.noProfiles': 'プロファイルが見つかりません。',
'storage.title': 'ストレージの場所', 'storage.new': '+ 新しい場所',
'storage.empty': 'ストレージの場所が設定されていません。バックアッププロファイルを設定するために作成してください。',
'storage.newLocation': '新しいストレージの場所', 'storage.name': '名前', 'storage.namePlaceholder': 'メインバックアップ',
'storage.directory': 'ディレクトリ', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'ストレージの場所が保存されました。', 'storage.deleted': '場所が削除されました。',
'storage.confirmDelete': 'このストレージの場所を削除しますか?',
'settings.title': '設定', 'settings.language': '言語',
'settings.languageDesc': 'インターフェースの言語を選択', 'settings.auth': 'アクセス制御',
'settings.authEnabled': 'アクセスにユーザー名とパスワードを要求する',
'settings.username': 'ユーザー名', 'settings.password': 'パスワード(変更しない場合は空白)',
'settings.saveSettings': '設定を保存', 'settings.saved': '設定が保存されました。',
'about.title': 'アプリについて',
'about.description': '増分スナップショットと選択的復元をサポートするDockerボリュームのバックアップと復元のためのWebアプリケーション。',
'about.currentVersion': '現在のバージョン', 'about.latestVersion': '最新バージョン',
'about.checking': '確認中...', 'about.upToDate': '最新',
'about.updateAvailable': 'アップデートあり', 'about.update': '今すぐ更新',
'about.updating': '更新中...', 'about.changelog': '最新の変更',
'about.checkError': '最新バージョンを確認できませんでした。',
'about.updateSuccess': '更新が完了しました。再起動中...', 'about.updateError': '更新に失敗しました。',
'login.title': 'アクセス制限', 'login.subtitle': '続けるにはログインしてください',
'login.username': 'ユーザー名', 'login.password': 'パスワード', 'login.submit': 'ログイン',
'login.error': 'ユーザー名またはパスワードが正しくありません。', 'login.logout': 'ログアウト',
'progress.backup': 'バックアップの進捗', 'progress.restore': '復元の進捗',
'progress.containers': 'コンテナ', 'progress.completed': '完了',
'progress.remaining': '残り', 'progress.current': '現在のコンテナ',
'progress.files': 'ファイル', 'progress.detailedLog': '詳細ログ',
'progress.events': 'イベント', 'progress.noEvents': '詳細なイベントはまだありません。',
'progress.step': 'ステップ', 'progress.waiting': 'ファイル処理を待っています...',
'error.selectStorage': 'ストレージの場所を選択してください。',
'error.noFullBackup': '完全バックアップがありません。まず完全バックアップを実行してください。',
'error.selectFullBackup': '完全バックアップを選択してください。',
'error.selectFullBackupModal': '基本完全バックアップの選択',
'error.selectFullBackupDesc': '増分バックアップのベースとして使用する完全バックアップを選択:',
'error.selectVolume': '少なくとも1つのボリュームを選択してください。', 'error.selectContainer': '少なくとも1つのコンテナを選択してください。',
'volume.title': 'バックアップするボリュームを選択', 'volume.confirm': '選択を確認',
'restore.title': '復元するコンテナを選択', 'restore.confirm': '選択したものを復元',
'restore.confirmPrompt': 'プロファイルの選択したバックアップを復元',
'scope.volumes': 'ボリュームのみ', 'scope.container': 'コンテナ全体',
'status.completed': '完了', 'status.partial': '部分的', 'status.error': 'エラー', 'status.running': '実行中',
};
const fa = {
'nav.dashboard': 'داشبورد', 'nav.storage': 'مکان‌های ذخیره‌سازی',
'nav.profiles': 'پروفایل‌های پشتیبان‌گیری', 'nav.runs': 'اجرای پشتیبان‌گیری',
'nav.backups': 'پشتیبان‌ها', 'nav.settings': 'تنظیمات', 'nav.about': 'درباره برنامه',
'dashboard.title': 'داشبورد', 'dashboard.totalContainers': 'مجموع کانتینرها',
'dashboard.activeConnections': 'اتصالات فعال', 'dashboard.backupProfiles': 'پروفایل‌های پشتیبان‌گیری',
'dashboard.configuredProfiles': 'پروفایل‌های پیکربندی‌شده', 'dashboard.successful': 'موفق',
'dashboard.totalSuccessful': 'مجموع موفق', 'dashboard.failed': 'ناموفق',
'dashboard.totalFailed': 'مجموع ناموفق', 'dashboard.recentRuns': 'آخرین اجراها',
'dashboard.createProfile': '+ ایجاد پروفایل',
'table.id': 'شناسه', 'table.profile': 'پروفایل', 'table.mode': 'حالت', 'table.status': 'وضعیت',
'table.containers': 'کانتینرها', 'table.files': 'فایل‌ها', 'table.size': 'حجم',
'table.started': 'شروع', 'table.duration': 'مدت', 'table.actions': 'عملیات',
'table.date': 'تاریخ', 'table.type': 'نوع', 'table.directory': 'پوشه', 'table.name': 'نام',
'profiles.title': 'پروفایل‌های پشتیبان‌گیری', 'profiles.create': '+ ایجاد پروفایل', 'profiles.reload': 'بارگذاری مجدد',
'profiles.empty': 'هیچ پروفایلی ذخیره نشده.', 'profiles.newProfile': 'پروفایل جدید', 'profiles.editProfile': 'ویرایش پروفایل',
'profiles.name': 'نام پروفایل', 'profiles.namePlaceholder': 'پشتیبان پایگاه داده اصلی',
'profiles.storageLocation': 'مکان ذخیره‌سازی', 'profiles.storageLocationPlaceholder': 'یک مکان انتخاب کنید...',
'profiles.backupScope': 'محدوده پشتیبان‌گیری', 'profiles.scopeVolumes': 'فقط والیوم‌ها',
'profiles.scopeVolumesDesc': 'از والیوم‌ها و bind mount پشتیبان می‌گیرد', 'profiles.scopeContainer': 'کل کانتینر',
'profiles.scopeContainerDesc': 'یک tar به ازای هر کانتینر از / ایجاد می‌کند',
'profiles.containers': 'کانتینرها', 'profiles.refreshContainers': 'بروزرسانی لیست',
'profiles.save': 'ذخیره پروفایل', 'profiles.cancel': 'انصراف', 'profiles.saved': 'پروفایل ذخیره شد.',
'profiles.deleted': 'پروفایل حذف شد.', 'profiles.confirmDelete': 'حذف پروفایل',
'action.run': 'اجرا', 'action.running': 'در حال اجرا...', 'action.edit': 'ویرایش', 'action.delete': 'حذف',
'action.restore': 'بازیابی', 'action.restoring': 'در حال بازیابی...', 'action.refresh': 'بروزرسانی',
'action.save': 'ذخیره', 'action.cancel': 'انصراف', 'action.close': 'بستن',
'action.confirm': 'تأیید', 'action.selectAll': 'انتخاب همه',
'mode.full': 'کامل', 'mode.incremental': 'افزایشی', 'mode.backupMode': 'حالت پشتیبان‌گیری',
'runs.title': 'اجرای پشتیبان‌گیری', 'runs.allRuns': 'همه اجراها', 'runs.empty': 'اجرایی یافت نشد.',
'backups.title': 'پشتیبان‌ها', 'backups.noBackups': 'هیچ پشتیبانی انجام نشده.', 'backups.noProfiles': 'پروفایلی یافت نشد.',
'storage.title': 'مکان‌های ذخیره‌سازی', 'storage.new': '+ مکان جدید',
'storage.empty': 'هیچ مکانی پیکربندی نشده. برای تنظیم پروفایل‌ها یک مکان ایجاد کنید.',
'storage.newLocation': 'مکان ذخیره‌سازی جدید', 'storage.name': 'نام', 'storage.namePlaceholder': 'پشتیبان اصلی',
'storage.directory': 'پوشه', 'storage.directoryPlaceholder': '/srv/docker-backups',
'storage.saved': 'مکان ذخیره‌سازی ذخیره شد.', 'storage.deleted': 'مکان حذف شد.',
'storage.confirmDelete': 'این مکان ذخیره‌سازی حذف شود؟',
'settings.title': 'تنظیمات', 'settings.language': 'زبان',
'settings.languageDesc': 'زبان رابط کاربری را انتخاب کنید', 'settings.auth': 'کنترل دسترسی',
'settings.authEnabled': 'برای دسترسی نام کاربری و رمز عبور لازم باشد',
'settings.username': 'نام کاربری', 'settings.password': 'رمز عبور (برای عدم تغییر خالی بگذارید)',
'settings.saveSettings': 'ذخیره تنظیمات', 'settings.saved': 'تنظیمات ذخیره شد.',
'about.title': 'درباره برنامه',
'about.description': 'برنامه وب برای پشتیبان‌گیری و بازیابی والیوم‌های Docker با پشتیبانی از snapshotهای افزایشی و بازیابی انتخابی.',
'about.currentVersion': 'نسخه فعلی', 'about.latestVersion': 'آخرین نسخه',
'about.checking': 'در حال بررسی...', 'about.upToDate': 'به‌روز است',
'about.updateAvailable': 'بروزرسانی موجود است', 'about.update': 'اکنون بروزرسانی کن',
'about.updating': 'در حال بروزرسانی...', 'about.changelog': 'آخرین تغییرات',
'about.checkError': 'بررسی آخرین نسخه ممکن نبود.',
'about.updateSuccess': 'بروزرسانی کامل شد. در حال راه‌اندازی مجدد...', 'about.updateError': 'بروزرسانی ناموفق بود.',
'login.title': 'دسترسی محدود', 'login.subtitle': 'برای ادامه وارد شوید',
'login.username': 'نام کاربری', 'login.password': 'رمز عبور', 'login.submit': 'ورود',
'login.error': 'نام کاربری یا رمز عبور نادرست است.', 'login.logout': 'خروج',
'progress.backup': 'پیشرفت پشتیبان‌گیری', 'progress.restore': 'پیشرفت بازیابی',
'progress.containers': 'کانتینرها', 'progress.completed': 'تکمیل شده',
'progress.remaining': 'باقی‌مانده', 'progress.current': 'کانتینر فعلی',
'progress.files': 'فایل‌ها', 'progress.detailedLog': 'گزارش تفصیلی',
'progress.events': 'رویداد', 'progress.noEvents': 'هنوز رویداد تفصیلی‌ای نیست.',
'progress.step': 'مرحله', 'progress.waiting': 'منتظر پردازش فایل...',
'error.selectStorage': 'یک مکان ذخیره‌سازی انتخاب کنید.',
'error.noFullBackup': 'پشتیبان کامل موجود نیست. ابتدا یک پشتیبان کامل انجام دهید.',
'error.selectFullBackup': 'یک پشتیبان کامل انتخاب کنید.',
'error.selectFullBackupModal': 'انتخاب پشتیبان کامل پایه',
'error.selectFullBackupDesc': 'پشتیبان کامل مورد استفاده به عنوان پایه برای پشتیبان افزایشی را انتخاب کنید:',
'error.selectVolume': 'حداقل یک والیوم انتخاب کنید.', 'error.selectContainer': 'حداقل یک کانتینر انتخاب کنید.',
'volume.title': 'انتخاب والیوم‌ها برای پشتیبان‌گیری', 'volume.confirm': 'تأیید انتخاب',
'restore.title': 'انتخاب کانتینرها برای بازیابی', 'restore.confirm': 'بازیابی انتخاب‌شده‌ها',
'restore.confirmPrompt': 'بازیابی پشتیبان انتخاب‌شده برای پروفایل',
'scope.volumes': 'فقط والیوم‌ها', 'scope.container': 'کل کانتینر',
'status.completed': 'تکمیل شد', 'status.partial': 'ناقص', 'status.error': 'خطا', 'status.running': 'در حال اجرا',
};
window.TRANSLATIONS = { 'pt-BR': ptBR, en, es, de, pl, it, ru, zh, ja, fa };
})();

View File

@ -166,6 +166,7 @@ class BackupService {
mode: effectiveMode,
backupScope,
backupDir: profile.backupDir,
basedOnFullBackupId: options.basedOnFullBackupId || null,
status: 'ok',
containers: [],
};

View File

@ -2,12 +2,20 @@ const express = require('express');
const path = require('path');
const fs = require('fs/promises');
const crypto = require('crypto');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const config = require('./config');
const JsonStore = require('./store');
const DockerService = require('./dockerService');
const BackupService = require('./backupService');
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
}
async function main() {
await fs.mkdir(config.dataDir, { recursive: true });
@ -25,13 +33,134 @@ async function main() {
const app = express();
app.use(express.json());
// ─── Auth middleware ──────────────────────────────────────
async function authMiddleware(request, response, next) {
const settings = await store.getSettings();
if (!settings.requireAuth) {
return next();
}
const token = request.headers['x-auth-token'];
if (!token) {
return response.status(401).json({ error: 'Unauthorized' });
}
const expected = hashPassword(`${settings.username}:${settings.passwordHash}:${settings.username}`);
if (token !== expected) {
return response.status(401).json({ error: 'Unauthorized' });
}
return next();
}
// Static files served without auth (login page needs to load)
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) => {
// Login endpoint (public)
app.post('/api/login', async (request, response) => {
try {
const settings = await store.getSettings();
if (!settings.requireAuth) {
return response.json({ token: null, requireAuth: false });
}
const { username, password } = request.body || {};
if (!username || !password) {
return response.status(400).json({ error: 'Informe usuario e senha.' });
}
if (username !== settings.username || hashPassword(password) !== settings.passwordHash) {
return response.status(401).json({ error: 'Usuario ou senha incorretos.' });
}
const token = hashPassword(`${settings.username}:${settings.passwordHash}:${settings.username}`);
return response.json({ token, requireAuth: true });
} catch (error) {
return response.status(500).json({ error: error.message });
}
});
// Auth check endpoint (public)
app.get('/api/auth-status', async (_request, response) => {
try {
const settings = await store.getSettings();
response.json({ requireAuth: settings.requireAuth });
} catch (error) {
response.status(500).json({ error: error.message });
}
});
// Settings endpoints (auth-protected)
app.get('/api/settings', authMiddleware, async (_request, response) => {
try {
const settings = await store.getSettings();
response.json({ language: settings.language, requireAuth: settings.requireAuth, username: settings.username });
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.post('/api/settings', authMiddleware, async (request, response) => {
try {
const payload = request.body || {};
const current = await store.getSettings();
const update = {
language: payload.language || current.language,
requireAuth: typeof payload.requireAuth === 'boolean' ? payload.requireAuth : current.requireAuth,
username: payload.username !== undefined ? String(payload.username).trim() : current.username,
passwordHash: current.passwordHash,
};
if (payload.password) {
update.passwordHash = hashPassword(payload.password);
}
if (update.requireAuth && (!update.username || !update.passwordHash)) {
return response.status(400).json({ error: 'Defina usuario e senha para ativar autenticacao.' });
}
await store.saveSettings(update);
response.json({ ok: true });
} catch (error) {
response.status(500).json({ error: error.message });
}
});
// About endpoint (auth-protected)
app.get('/api/about', authMiddleware, async (_request, response) => {
try {
const pkgPath = path.join(process.cwd(), 'package.json');
const pkgRaw = await fs.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(pkgRaw);
response.json({ currentVersion: pkg.version, name: pkg.name || 'dockerbackup' });
} catch (error) {
response.status(500).json({ error: error.message });
}
});
// Update endpoint (auth-protected)
app.post('/api/update', authMiddleware, async (_request, response) => {
try {
await execFileAsync('git', ['pull', '--ff-only', 'origin', 'main'], { cwd: process.cwd() });
try {
await execFileAsync('npm', ['install', '--omit=dev'], { cwd: process.cwd() });
} catch {
// Non-fatal: deps may already be up to date
}
response.json({ ok: true });
setTimeout(() => process.exit(0), 500);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.get('/api/containers', authMiddleware, async (_request, response) => {
try {
const containers = await dockerService.listContainers();
response.json(containers);
@ -40,7 +169,7 @@ async function main() {
}
});
app.get('/api/containers/:containerId/mounts', async (request, response) => {
app.get('/api/containers/:containerId/mounts', authMiddleware, async (request, response) => {
try {
const inspect = await dockerService.inspectContainer(request.params.containerId);
const mounts = (inspect.Mounts || [])
@ -58,7 +187,7 @@ async function main() {
}
});
app.get('/api/profiles', async (_request, response) => {
app.get('/api/profiles', authMiddleware, async (_request, response) => {
try {
const profiles = await store.listProfiles();
response.json(profiles);
@ -67,11 +196,27 @@ async function main() {
}
});
app.post('/api/profiles', async (request, response) => {
app.post('/api/profiles', authMiddleware, 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.' });
if (!payload.name || !Array.isArray(payload.containerIds) || !payload.containerIds.length) {
response.status(400).json({ error: 'Informe nome, local de armazenamento e ao menos um container.' });
return;
}
let resolvedBackupDir = payload.backupDir;
if (payload.storageLocationId) {
const locations = await store.listStorageLocations();
const loc = locations.find((l) => l.id === payload.storageLocationId);
if (!loc) {
response.status(400).json({ error: 'Local de armazenamento nao encontrado.' });
return;
}
resolvedBackupDir = loc.directory;
}
if (!resolvedBackupDir) {
response.status(400).json({ error: 'Informe o local de armazenamento.' });
return;
}
@ -85,7 +230,8 @@ async function main() {
id: payload.id,
createdAt: existing?.createdAt,
name: payload.name.trim(),
backupDir: payload.backupDir.trim(),
backupDir: resolvedBackupDir.trim(),
storageLocationId: payload.storageLocationId || existing?.storageLocationId || null,
containerIds: payload.containerIds,
mode: existing?.mode || 'full',
backupScope: payload.backupScope || existing?.backupScope || 'volumes',
@ -98,7 +244,7 @@ async function main() {
}
});
app.delete('/api/profiles/:profileId', async (request, response) => {
app.delete('/api/profiles/:profileId', authMiddleware, async (request, response) => {
try {
const profile = await store.getProfile(request.params.profileId);
await store.deleteProfile(request.params.profileId);
@ -115,7 +261,7 @@ async function main() {
}
});
app.get('/api/profiles/:profileId/backups', async (request, response) => {
app.get('/api/profiles/:profileId/backups', authMiddleware, async (request, response) => {
try {
const backups = await store.listBackups(request.params.profileId);
response.json(backups);
@ -124,10 +270,11 @@ async function main() {
}
});
app.post('/api/profiles/:profileId/run', async (request, response) => {
app.post('/api/profiles/:profileId/run', authMiddleware, async (request, response) => {
try {
const profileId = request.params.profileId;
const requestedMode = request.body?.mode;
const basedOnFullBackupId = request.body?.basedOnFullBackupId || null;
if (requestedMode && !['full', 'incremental'].includes(requestedMode)) {
response.status(400).json({ error: 'Modo de backup invalido.' });
return;
@ -155,6 +302,7 @@ async function main() {
void backupService.runProfile(profileId, {
mode: requestedMode,
basedOnFullBackupId,
onProgress: (progressSnapshot) => {
const currentJob = runJobs.get(runId);
if (!currentJob) {
@ -189,7 +337,7 @@ async function main() {
}
});
app.get('/api/runs/:runId', (request, response) => {
app.get('/api/runs/:runId', authMiddleware, (request, response) => {
const job = runJobs.get(request.params.runId);
if (!job) {
response.status(404).json({ error: 'Execucao nao encontrada.' });
@ -209,7 +357,7 @@ async function main() {
});
});
app.post('/api/profiles/:profileId/restore', async (request, response) => {
app.post('/api/profiles/:profileId/restore', authMiddleware, async (request, response) => {
try {
const profileId = request.params.profileId;
if (!request.body?.backupId) {
@ -279,6 +427,42 @@ async function main() {
}
});
app.get('/api/storage-locations', authMiddleware, async (_request, response) => {
try {
const locations = await store.listStorageLocations();
response.json(locations);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.post('/api/storage-locations', authMiddleware, async (request, response) => {
try {
const payload = request.body || {};
if (!payload.name || !payload.directory) {
response.status(400).json({ error: 'Informe nome e diretorio do local de armazenamento.' });
return;
}
const location = await store.saveStorageLocation({
id: payload.id,
name: payload.name.trim(),
directory: payload.directory.trim(),
});
response.status(payload.id ? 200 : 201).json(location);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.delete('/api/storage-locations/:id', authMiddleware, async (request, response) => {
try {
await store.deleteStorageLocation(request.params.id);
response.status(204).end();
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.listen(config.port, () => {
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
});

View File

@ -13,7 +13,7 @@ class JsonStore {
try {
await fs.access(this.filePath);
} catch {
await fs.writeFile(this.filePath, JSON.stringify({ profiles: [], backups: [] }, null, 2));
await fs.writeFile(this.filePath, JSON.stringify({ profiles: [], backups: [], storageLocations: [], settings: {} }, null, 2));
}
}
@ -22,6 +22,8 @@ class JsonStore {
const parsed = JSON.parse(raw);
parsed.profiles ||= [];
parsed.backups ||= [];
parsed.storageLocations ||= [];
parsed.settings ||= {};
return parsed;
}
@ -136,6 +138,62 @@ class JsonStore {
return [];
}
async listStorageLocations() {
const data = await this.read();
return data.storageLocations;
}
async saveStorageLocation(input) {
const now = new Date().toISOString();
const location = {
id: input.id || randomUUID(),
name: input.name,
directory: input.directory,
updatedAt: now,
createdAt: input.createdAt || now,
};
await this.write((data) => {
const index = data.storageLocations.findIndex((item) => item.id === location.id);
if (index >= 0) {
data.storageLocations[index] = location;
} else {
data.storageLocations.push(location);
}
return data;
});
return location;
}
async deleteStorageLocation(locationId) {
await this.write((data) => {
data.storageLocations = data.storageLocations.filter((item) => item.id !== locationId);
return data;
});
}
async getSettings() {
const data = await this.read();
return {
language: data.settings.language || 'pt-BR',
requireAuth: data.settings.requireAuth || false,
username: data.settings.username || '',
passwordHash: data.settings.passwordHash || '',
};
}
async saveSettings(input) {
await this.write((data) => {
data.settings = {
...data.settings,
...input,
};
return data;
});
return this.getSettings();
}
async getLastContainerBackupTime(profileId, containerId) {
const backups = await this.listBackups(profileId);
const ordered = backups