From 990b0dea4d6b6ac374add1b76076bce667866549 Mon Sep 17 00:00:00 2001
From: Alexander Sabino <32822107+asabino2@users.noreply.github.com>
Date: Sat, 9 May 2026 10:43:50 +0100
Subject: [PATCH] Refactor code structure for improved readability and
maintainability
---
README.md | 40 +-
package.json | 2 +-
public/app.js | 591 +++++++++++++++++++++++---
public/index.html | 221 ++++++++--
public/styles.css | 238 +++++++++++
public/translations.js | 939 +++++++++++++++++++++++++++++++++++++++++
src/backupService.js | 1 +
src/server.js | 208 ++++++++-
src/store.js | 60 ++-
9 files changed, 2188 insertions(+), 112 deletions(-)
create mode 100644 public/translations.js
diff --git a/README.md b/README.md
index ac4f15e..1dca497 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -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
+## � 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
+
+---
+
+## �🗄️ Visão geral
O `dockerbackup` fornece:
diff --git a/package.json b/package.json
index 4267c84..c6306e0 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/public/app.js b/public/app.js
index cf95238..7b3f710 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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 = '
Nenhum servidor encontrado.
';
+
+ if (!state.storageLocations.length) {
+ list.innerHTML = 'Nenhum local de armazenamento configurado. Crie um para poder configurar backup profiles.
';
return;
}
- list.innerHTML = state.containers.map((c) => `
-
-
${escapeHtml(c.name)}
- ${escapeHtml(c.image)}
- ${escapeHtml(c.status)} · ${escapeHtml(c.state)}
-
+
+ list.innerHTML = `
+
+ | Nome | Diretório | Ações |
+
+ ${state.storageLocations.map((loc) => `
+
+ | ${escapeHtml(loc.name)} |
+ ${escapeHtml(loc.directory)} |
+
+
+ |
+
+ `).join('')}
+
+
+ `;
+}
+
+function populateStorageLocationDropdown() {
+ const select = elements.storageLocationSelect;
+ if (!select) return;
+ const current = select.value;
+ select.innerHTML = '' +
+ state.storageLocations.map((loc) =>
+ ``
+ ).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) => `
+
`).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,40 +331,81 @@ async function renderBackupsView() {
const backups = await api(`/api/profiles/${p.id}/backups`);
return { profile: p, backups };
}));
- host.innerHTML = rows.map(({ profile, backups }) => `
-
-
- `).join('');
+ `;
+ }).join('');
+}
+
+function renderBackupRow(b, profile, isFull) {
+ const hasRestorable = (b.containers || []).some((c) => c.status === 'ok');
+ const indent = isFull ? '' : ' ↳ ';
+ const modeLabel = isFull ? 'Full' : 'Incremental';
+ return `
+
+ | ${indent}${escapeHtml(new Date(b.createdAt).toLocaleString('pt-BR'))} |
+ ${escapeHtml(modeLabel)} |
+ ${escapeHtml(b.status)} |
+ ${escapeHtml((b.containers || []).map((c) => c.containerName).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]) =>
+ `
`
+ ).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();
\ No newline at end of file
+// Apply translations early (before auth check so login page is translated)
+applyTranslations();
+checkAuthAndInit();
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index a24b125..a79e875 100644
--- a/public/index.html
+++ b/public/index.html
@@ -28,9 +28,9 @@
-
@@ -48,30 +48,27 @@
- Backups
+ Backups
+
+
+
+
+
+ Configurações
+
+
+
+
+
+ Sobre
-
CONFIGURATION
-
-
- -
-
-
- Storage Locations
-
-
- -
-
-
- Naming Rules
-
-
-
+
-
-
@@ -153,15 +150,6 @@
-
-
-
-
+
-
-
Sem locais de armazenamento configurados.
+
+
-
-
-
-
Sem regras de nomenclatura configuradas.
+
+
+
+
+
+
Idioma
+
Selecione o idioma de toda a interface
+
+
+
+
Controle de Acesso
+
+
+
+
+ Salvar configurações
+
+
+
+
+
+
+
+
+
+

+
DockerBackup
+
+
Aplicação web para backup e restauração de volumes Docker com suporte a snapshots incrementais e restore seletivo.
+
+
+
+ Versão atual
+ —
+
+
+ Última versão
+ Verificando...
+
+
+
+
+
+ Atualizar agora
+
+
+
+
Últimas alterações
+
+
0.0.3
+
+ - Suporte a múltiplos idiomas (10 idiomas)
+ - Aba de configurações com controle de acesso (usuário e senha)
+ - Aba "Sobre" com verificação de versão via GitHub e atualização automática
+
+
0.0.2
+
+ - Adicionado gerenciamento de Storage Locations
+ - Backup incremental com seleção de full backup base
+ - Agrupamento visual de backups incrementais sob o full
+ - Removidas abas Servers e Naming Rules
+
+
0.0.1
+
+ - Versão inicial: backup e restore de volumes Docker
+
+
+
+
@@ -234,6 +309,27 @@
+
+
+
+

+
Acesso restrito
+
Faça login para continuar
+
+
+
+
@@ -282,8 +378,10 @@
-
-
+
+
+
+
+
+
+
+
Selecione o backup full que será utilizado como base para o backup incremental:
+
+
+ Confirmar
+
+
+
+
+
+
+
+