feat: update version to 0.2.0 and add source management features

- Updated README.md to reflect new version and changelog for version 0.2.0
- Updated package.json to version 0.2.0
- Added source management functionality in app.js:
  - Introduced state management for sources
  - Implemented UI for adding, editing, and deleting sources
  - Added source selection in profile forms
- Enhanced index.html with new source view and modal for source creation
- Added translations for source management in translations.js
- Updated dockerService.js to support TCP connections for sources
- Modified server.js to handle API routes for sources:
  - Added endpoints for checking socket availability, listing, creating, and deleting sources
- Updated store.js to manage sources in JSON storage
This commit is contained in:
Alexander Sabino 2026-05-10 15:50:27 +01:00
parent a58ee6299a
commit 6401559237
8 changed files with 649 additions and 12 deletions

View File

@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/VERSION-0.1.6-blue?style=flat-square" /> <img src="https://img.shields.io/badge/VERSION-0.2.0-blue?style=flat-square" />
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" /> <img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" /> <img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" /> <img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
@ -22,7 +22,12 @@ Versão atual: **0.1.4**
--- ---
## <20> Changelog ## <20> Changelog### [0.2.0] — 2026-05-11
#### Adicionado
- **Aba Source (Origens):** nova aba acima de Storage Locations para gerenciar origens de conexão Docker, com suporte a Unix socket, conexão direta (TCP porta 2375) e Docker Agent
- **Cascade de exclusão de source:** ao remover uma origem, todos os profiles e backups associados são automaticamente removidos
- **Seleção de source no profile:** cada backup profile pode ser vinculado a uma origem Docker específica
### [0.1.6] — 2026-05-11 ### [0.1.6] — 2026-05-11
#### Corrigido #### Corrigido

View File

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

View File

@ -56,6 +56,7 @@ const state = {
profiles: [], profiles: [],
storageLocations: [], storageLocations: [],
schedules: [], schedules: [],
sources: [],
activeRuns: new Map(), activeRuns: new Map(),
volumeSelections: {}, volumeSelections: {},
}; };
@ -94,6 +95,14 @@ const elements = {
storageLocationDir: document.querySelector('#storageLocationDir'), storageLocationDir: document.querySelector('#storageLocationDir'),
storageLocationIdField: document.querySelector('#storageFormId'), storageLocationIdField: document.querySelector('#storageFormId'),
storageLocationsList: document.querySelector('#storageLocationsList'), storageLocationsList: document.querySelector('#storageLocationsList'),
sourceFormModal: document.querySelector('#sourceFormModal'),
sourceForm: document.querySelector('#sourceForm'),
sourceFormId: document.querySelector('#sourceFormId'),
sourceFormName: document.querySelector('#sourceFormName'),
sourceFormHost: document.querySelector('#sourceFormHost'),
sourceFormPort: document.querySelector('#sourceFormPort'),
sourcesList: document.querySelector('#sourcesList'),
profileSourceSelect: document.querySelector('#profileSourceId'),
}; };
// ─── View navigation ────────────────────────────────────── // ─── View navigation ──────────────────────────────────────
@ -124,6 +133,9 @@ function navigateTo(viewName) {
if (viewName === 'storage') { if (viewName === 'storage') {
loadStorageLocations(); loadStorageLocations();
} }
if (viewName === 'source') {
loadSources();
}
if (viewName === 'settings') { if (viewName === 'settings') {
loadSettingsView(); loadSettingsView();
} }
@ -156,6 +168,7 @@ function closeProfileModal() {
document.querySelector('#openCreateProfileModal')?.addEventListener('click', () => { document.querySelector('#openCreateProfileModal')?.addEventListener('click', () => {
resetForm(); resetForm();
populateStorageLocationDropdown(); populateStorageLocationDropdown();
populateSourceDropdown();
openProfileModal('Novo Profile'); openProfileModal('Novo Profile');
}); });
@ -288,8 +301,204 @@ elements.storageLocationsList?.addEventListener('click', async (e) => {
} }
}); });
// ─── Directory Browser Modal ────────────────────────────── // ─── Sources ──────────────────────────────────────────────
let _dirBrowserCurrentPath = '/'; const SOURCE_TYPE_LABELS = {
'unix-socket': 'Unix Socket',
'direct': 'Direto (TCP)',
'agent': 'Docker Agent',
};
let _unixSocketAvailable = false;
async function checkUnixSocket() {
try {
const result = await api('/api/sources/check-unix-socket');
_unixSocketAvailable = result.available === true;
} catch {
_unixSocketAvailable = false;
}
const card = document.querySelector('#unixSocketRadioCard');
const msg = document.querySelector('#unixSocketUnavailableMsg');
const radio = document.querySelector('#sourceTypeUnixSocket');
if (card) card.style.opacity = _unixSocketAvailable ? '' : '0.45';
if (radio) radio.disabled = !_unixSocketAvailable;
if (msg) msg.classList.toggle('hidden', _unixSocketAvailable);
}
async function loadSources() {
try {
state.sources = await api('/api/sources');
renderSourcesList();
populateSourceDropdown();
} catch (error) {
showToast(error.message, true);
}
}
function renderSourcesList() {
const list = elements.sourcesList;
if (!list) return;
if (!state.sources.length) {
list.innerHTML = '<p class="empty-state" data-i18n="source.empty">Nenhuma origem configurada. Crie uma para poder conectar a diferentes hosts Docker.</p>';
applyTranslations();
return;
}
list.innerHTML = `
<table class="data-table">
<thead><tr><th data-i18n="source.name">Nome</th><th data-i18n="source.type">Tipo</th><th>Conexão</th><th>Ações</th></tr></thead>
<tbody>
${state.sources.map((src) => {
const connInfo = src.type === 'unix-socket'
? (src.socketPath || '/var/run/docker.sock')
: `${src.host || '—'}:${src.port || 2375}`;
return `
<tr>
<td><strong>${escapeHtml(src.name)}</strong></td>
<td>${escapeHtml(SOURCE_TYPE_LABELS[src.type] || src.type)}</td>
<td><code>${escapeHtml(connInfo)}</code></td>
<td>
<button class="btn btn--ghost btn--sm" data-source-action="delete" data-source-id="${escapeHtml(src.id)}">Excluir</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
applyTranslations();
}
function populateSourceDropdown() {
const select = elements.profileSourceSelect;
if (!select) return;
const current = select.value;
select.innerHTML = `<option value="">Padrão (socket local)</option>` +
state.sources.map((src) =>
`<option value="${escapeHtml(src.id)}">${escapeHtml(src.name)}${escapeHtml(SOURCE_TYPE_LABELS[src.type] || src.type)}</option>`
).join('');
if (current) select.value = current;
}
function openSourceModal() {
document.querySelector('#sourceModalTitle').textContent = 'Nova Origem';
elements.sourceForm?.reset();
if (elements.sourceFormId) elements.sourceFormId.value = '';
// Default to 'direct' type
const directRadio = document.querySelector('#sourceTypeDirect');
if (directRadio) directRadio.checked = true;
updateSourceTypeFields('direct');
elements.sourceFormModal?.classList.remove('hidden');
elements.sourceFormModal?.setAttribute('aria-hidden', 'false');
checkUnixSocket();
}
function closeSourceModal() {
elements.sourceFormModal?.classList.add('hidden');
elements.sourceFormModal?.setAttribute('aria-hidden', 'true');
}
function updateSourceTypeFields(type) {
const hostFields = document.querySelector('#sourceHostFields');
if (!hostFields) return;
if (type === 'unix-socket') {
hostFields.classList.add('hidden');
} else {
hostFields.classList.remove('hidden');
const portInput = elements.sourceFormPort;
if (portInput && !portInput.value) {
portInput.value = type === 'agent' ? '9000' : '2375';
}
}
}
async function saveSource(event) {
event.preventDefault();
const type = document.querySelector('input[name="sourceType"]:checked')?.value;
if (!type) {
showToast('Selecione o tipo de origem.', true);
return;
}
const payload = {
id: elements.sourceFormId?.value || undefined,
name: elements.sourceFormName?.value.trim(),
type,
host: type !== 'unix-socket' ? elements.sourceFormHost?.value.trim() : undefined,
port: type !== 'unix-socket' ? (Number(elements.sourceFormPort?.value) || null) : undefined,
socketPath: type === 'unix-socket' ? '/var/run/docker.sock' : undefined,
};
try {
await api('/api/sources', { method: 'POST', body: JSON.stringify(payload) });
closeSourceModal();
await loadSources();
showToast(t('source.saved'));
} catch (error) {
showToast(error.message, true);
}
}
document.querySelector('#openCreateSourceModal')?.addEventListener('click', openSourceModal);
document.querySelector('#cancelSourceForm')?.addEventListener('click', closeSourceModal);
document.querySelector('#sourceModalClose')?.addEventListener('click', closeSourceModal);
elements.sourceFormModal?.addEventListener('click', (e) => {
if (e.target.closest('[data-action="close-source-modal"]')) closeSourceModal();
});
elements.sourceForm?.addEventListener('submit', saveSource);
document.querySelectorAll('input[name="sourceType"]').forEach((radio) => {
radio.addEventListener('change', (e) => updateSourceTypeFields(e.target.value));
});
elements.sourcesList?.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-source-action="delete"]');
if (!btn) return;
const id = btn.dataset.sourceId;
let impact = { profileCount: 0, profileNames: [], backupCount: 0 };
try {
impact = await api(`/api/sources/${id}/impact`);
} catch {
// Non-fatal
}
let message = t('source.confirmDelete');
if (impact.profileCount > 0) {
const names = impact.profileNames.map((n) => `${n}`).join('\n');
message =
`⚠️ ATENÇÃO: Esta ação também irá excluir permanentemente:\n\n` +
` ${impact.profileCount} profile(s) de backup:\n${names}\n\n` +
` ${impact.backupCount} backup(s) registrado(s) desses profiles\n\n` +
`Deseja continuar?`;
}
if (!window.confirm(message)) return;
try {
await api(`/api/sources/${id}`, { method: 'DELETE' });
await Promise.all([loadSources(), loadProfiles()]);
showToast(impact.profileCount > 0
? `Origem removida junto com ${impact.profileCount} profile(s) e ${impact.backupCount} backup(s).`
: t('source.deleted'));
} catch (error) {
showToast(error.message, true);
}
});
// When source changes in profile form, reload containers for that source
elements.profileSourceSelect?.addEventListener('change', async () => {
const sourceId = elements.profileSourceSelect.value || null;
const url = sourceId ? `/api/containers?sourceId=${encodeURIComponent(sourceId)}` : '/api/containers';
try {
state.containers = await api(url);
renderContainers();
} catch {
// Non-fatal
}
});
// ─── Directory Browser Modal ──────────────────────────────let _dirBrowserCurrentPath = '/';
function openDirBrowser() { function openDirBrowser() {
const initial = elements.storageLocationDir.value.trim() || '/'; const initial = elements.storageLocationDir.value.trim() || '/';
@ -1170,9 +1379,13 @@ function fillForm(profile) {
elements.profileId.value = profile.id; elements.profileId.value = profile.id;
elements.profileName.value = profile.name; elements.profileName.value = profile.name;
populateStorageLocationDropdown(); populateStorageLocationDropdown();
populateSourceDropdown();
if (profile.storageLocationId) { if (profile.storageLocationId) {
elements.storageLocationSelect.value = profile.storageLocationId; elements.storageLocationSelect.value = profile.storageLocationId;
} }
if (elements.profileSourceSelect) {
elements.profileSourceSelect.value = profile.sourceId || '';
}
const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes'; const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true; document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
state.volumeSelections = Object.assign({}, profile.volumeSelections || {}); state.volumeSelections = Object.assign({}, profile.volumeSelections || {});
@ -1220,6 +1433,7 @@ async function saveProfile(event) {
id: elements.profileId.value || undefined, id: elements.profileId.value || undefined,
name: elements.profileName.value, name: elements.profileName.value,
storageLocationId, storageLocationId,
sourceId: elements.profileSourceSelect?.value || undefined,
containerIds: selectedContainerIds, containerIds: selectedContainerIds,
backupScope, backupScope,
volumeSelections, volumeSelections,
@ -1929,7 +2143,7 @@ document.querySelector('#aboutUpdateBtn')?.addEventListener('click', async () =>
async function init() { async function init() {
applyTranslations(); applyTranslations();
try { try {
await Promise.all([loadContainers(), loadProfiles(), loadStorageLocations()]); await Promise.all([loadContainers(), loadProfiles(), loadStorageLocations(), loadSources()]);
await updateDashboard(); await updateDashboard();
} catch (error) { } catch (error) {
showToast(error.message, true); showToast(error.message, true);

View File

@ -27,6 +27,12 @@
<span>Dashboard</span> <span>Dashboard</span>
</button> </button>
</li> </li>
<li>
<button class="nav-item" data-view="source">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<span data-i18n="nav.source">Origens</span>
</button>
</li>
<li> <li>
<button class="nav-item" data-view="storage"> <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> <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>
@ -223,6 +229,17 @@
</div> </div>
</div> </div>
<!-- VIEW: Source (Origens) -->
<div id="view-source" class="view hidden">
<div class="page-header">
<h1 class="page-title" data-i18n="source.title">Origens</h1>
<button id="openCreateSourceModal" class="btn btn--primary" data-i18n="source.new">+ Nova Origem</button>
</div>
<div class="card" id="sourceCard">
<div id="sourcesList"></div>
</div>
</div>
<!-- VIEW: Storage Locations --> <!-- VIEW: Storage Locations -->
<div id="view-storage" class="view hidden"> <div id="view-storage" class="view hidden">
<div class="page-header"> <div class="page-header">
@ -404,6 +421,13 @@
</select> </select>
</div> </div>
<div class="form-field">
<label for="profileSourceId" data-i18n="source.title">Origem Docker</label>
<select id="profileSourceId" name="sourceId">
<option value="" data-i18n="source.defaultSource">Padrão (socket local)</option>
</select>
</div>
<fieldset class="form-fieldset"> <fieldset class="form-fieldset">
<legend>Escopo do backup</legend> <legend>Escopo do backup</legend>
<div class="radio-grid"> <div class="radio-grid">
@ -452,6 +476,65 @@
</div> </div>
</div> </div>
<!-- Modal: Criar/Editar Source (Origem) -->
<div id="sourceFormModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-source-modal"></div>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="sourceModalTitle">
<div class="modal-header">
<h3 id="sourceModalTitle" data-i18n="source.newSource">Nova Origem</h3>
<button id="sourceModalClose" class="btn btn--ghost btn--sm" type="button" data-action="close-source-modal">Fechar</button>
</div>
<form id="sourceForm" class="profile-form">
<input type="hidden" id="sourceFormId" />
<div class="form-field">
<label for="sourceFormName" data-i18n="source.name">Nome</label>
<input id="sourceFormName" type="text" placeholder="Docker Local" required />
</div>
<fieldset class="form-fieldset">
<legend data-i18n="source.type">Tipo de conexão</legend>
<div class="radio-grid">
<label class="radio-card" id="unixSocketRadioCard">
<input type="radio" name="sourceType" value="unix-socket" id="sourceTypeUnixSocket" />
<span data-i18n="source.typeUnixSocket">Unix Socket</span>
<small data-i18n="source.typeUnixSocketDesc">/var/run/docker.sock</small>
</label>
<label class="radio-card">
<input type="radio" name="sourceType" value="direct" id="sourceTypeDirect" checked />
<span data-i18n="source.typeDirect">Conexão Direta</span>
<small data-i18n="source.typeDirectDesc">TCP porta 2375</small>
</label>
<label class="radio-card">
<input type="radio" name="sourceType" value="agent" id="sourceTypeAgent" />
<span data-i18n="source.typeAgent">Docker Agent</span>
<small data-i18n="source.typeAgentDesc">Via agente remoto</small>
</label>
</div>
<p id="unixSocketUnavailableMsg" class="form-hint form-hint--warn hidden" data-i18n="source.socketUnavailable">
Socket Unix não disponível neste ambiente.
</p>
</fieldset>
<div id="sourceHostFields">
<div class="form-field">
<label for="sourceFormHost" data-i18n="source.host">Host</label>
<input id="sourceFormHost" type="text" placeholder="192.168.1.100" />
</div>
<div class="form-field">
<label for="sourceFormPort" data-i18n="source.port">Porta</label>
<input id="sourceFormPort" type="number" placeholder="2375" min="1" max="65535" />
</div>
</div>
<div class="form-actions">
<button class="btn btn--primary" type="submit" data-i18n="source.save">Salvar</button>
<button class="btn btn--secondary" type="button" id="cancelSourceForm">Cancelar</button>
</div>
</form>
</div>
</div>
<!-- Modal: Criar/Editar Storage Location --> <!-- Modal: Criar/Editar Storage Location -->
<div id="storageLocationFormModal" class="modal hidden" aria-hidden="true"> <div id="storageLocationFormModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-storage-modal"></div> <div class="modal-backdrop" data-action="close-storage-modal"></div>

View File

@ -175,6 +175,30 @@
'status.partial': 'Partial', 'status.partial': 'Partial',
'status.error': 'Error', 'status.error': 'Error',
'status.running': 'Running', 'status.running': 'Running',
'nav.source': 'Origens',
'source.title': 'Origens',
'source.new': '+ Nova Origem',
'source.empty': 'Nenhuma origem configurada.',
'source.newSource': 'Nova Origem',
'source.name': 'Nome',
'source.namePlaceholder': 'Servidor remoto 1',
'source.type': 'Tipo de conexão',
'source.typeUnixSocket': 'Unix Socket',
'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'Conexão Direta',
'source.typeDirectDesc': 'TCP porta 2375',
'source.typeAgent': 'Docker Agent',
'source.typeAgentDesc': 'Via agente remoto',
'source.host': 'Host',
'source.hostPlaceholder': '192.168.1.100',
'source.port': 'Porta',
'source.socketUnavailable': 'Socket Unix não disponível neste ambiente.',
'source.defaultSource': 'Padrão (socket local)',
'source.saved': 'Origem salva.',
'source.deleted': 'Origem removida.',
'source.confirmDelete': 'Excluir esta origem?',
'source.save': 'Salvar',
}; };
const en = { const en = {
@ -271,6 +295,17 @@
'restore.confirmPrompt': 'Restore the selected backup for profile', 'restore.confirmPrompt': 'Restore the selected backup for profile',
'scope.volumes': 'volumes only', 'scope.container': 'entire container', 'scope.volumes': 'volumes only', 'scope.container': 'entire container',
'status.completed': 'Completed', 'status.partial': 'Partial', 'status.error': 'Error', 'status.running': 'Running', 'status.completed': 'Completed', 'status.partial': 'Partial', 'status.error': 'Error', 'status.running': 'Running',
'nav.source': 'Sources',
'source.title': 'Sources', 'source.new': '+ New Source', 'source.empty': 'No sources configured.',
'source.newSource': 'New Source', 'source.name': 'Name', 'source.namePlaceholder': 'Remote server 1',
'source.type': 'Connection type', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'Direct Connection', 'source.typeDirectDesc': 'TCP port 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'Via remote agent',
'source.host': 'Host', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'Port',
'source.socketUnavailable': 'Unix socket not available in this environment.',
'source.defaultSource': 'Default (local socket)', 'source.saved': 'Source saved.', 'source.deleted': 'Source removed.',
'source.confirmDelete': 'Delete this source?', 'source.save': 'Save',
}; };
const es = { const es = {
@ -354,6 +389,17 @@
'restore.confirmPrompt': 'Restaurar la copia seleccionada para el perfil', 'restore.confirmPrompt': 'Restaurar la copia seleccionada para el perfil',
'scope.volumes': 'solo volúmenes', 'scope.container': 'contenedor completo', 'scope.volumes': 'solo volúmenes', 'scope.container': 'contenedor completo',
'status.completed': 'Completado', 'status.partial': 'Parcial', 'status.error': 'Error', 'status.running': 'Ejecutando', 'status.completed': 'Completado', 'status.partial': 'Parcial', 'status.error': 'Error', 'status.running': 'Ejecutando',
'nav.source': 'Orígenes',
'source.title': 'Orígenes', 'source.new': '+ Nueva Origen', 'source.empty': 'No hay orígenes configurados.',
'source.newSource': 'Nueva Origen', 'source.name': 'Nombre', 'source.namePlaceholder': 'Servidor remoto 1',
'source.type': 'Tipo de conexión', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'Conexión Directa', 'source.typeDirectDesc': 'Puerto TCP 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'Vía agente remoto',
'source.host': 'Host', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'Puerto',
'source.socketUnavailable': 'Socket Unix no disponible en este entorno.',
'source.defaultSource': 'Predeterminado (socket local)', 'source.saved': 'Origen guardado.', 'source.deleted': 'Origen eliminado.',
'source.confirmDelete': '¿Eliminar esta origen?', 'source.save': 'Guardar',
}; };
const de = { const de = {
@ -436,6 +482,17 @@
'restore.confirmPrompt': 'Das ausgewählte Backup für das Profil wiederherstellen', 'restore.confirmPrompt': 'Das ausgewählte Backup für das Profil wiederherstellen',
'scope.volumes': 'nur Volumes', 'scope.container': 'gesamter Container', 'scope.volumes': 'nur Volumes', 'scope.container': 'gesamter Container',
'status.completed': 'Abgeschlossen', 'status.partial': 'Teilweise', 'status.error': 'Fehler', 'status.running': 'Läuft', 'status.completed': 'Abgeschlossen', 'status.partial': 'Teilweise', 'status.error': 'Fehler', 'status.running': 'Läuft',
'nav.source': 'Quellen',
'source.title': 'Quellen', 'source.new': '+ Neue Quelle', 'source.empty': 'Keine Quellen konfiguriert.',
'source.newSource': 'Neue Quelle', 'source.name': 'Name', 'source.namePlaceholder': 'Remote-Server 1',
'source.type': 'Verbindungstyp', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'Direkte Verbindung', 'source.typeDirectDesc': 'TCP-Port 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'Über Remote-Agent',
'source.host': 'Host', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'Port',
'source.socketUnavailable': 'Unix-Socket in dieser Umgebung nicht verfügbar.',
'source.defaultSource': 'Standard (lokaler Socket)', 'source.saved': 'Quelle gespeichert.', 'source.deleted': 'Quelle entfernt.',
'source.confirmDelete': 'Diese Quelle löschen?', 'source.save': 'Speichern',
}; };
const pl = { const pl = {
@ -519,6 +576,17 @@
'restore.confirmPrompt': 'Przywróć wybraną kopię dla profilu', 'restore.confirmPrompt': 'Przywróć wybraną kopię dla profilu',
'scope.volumes': 'tylko woluminy', 'scope.container': 'cały kontener', 'scope.volumes': 'tylko woluminy', 'scope.container': 'cały kontener',
'status.completed': 'Zakończono', 'status.partial': 'Częściowe', 'status.error': 'Błąd', 'status.running': 'Uruchomione', 'status.completed': 'Zakończono', 'status.partial': 'Częściowe', 'status.error': 'Błąd', 'status.running': 'Uruchomione',
'nav.source': 'Źródła',
'source.title': 'Źródła', 'source.new': '+ Nowe źródło', 'source.empty': 'Brak skonfigurowanych źródeł.',
'source.newSource': 'Nowe źródło', 'source.name': 'Nazwa', 'source.namePlaceholder': 'Zdalny serwer 1',
'source.type': 'Typ połączenia', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'Bezpośrednie połączenie', 'source.typeDirectDesc': 'Port TCP 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'Przez zdalnego agenta',
'source.host': 'Host', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'Port',
'source.socketUnavailable': 'Socket Unix niedostępny w tym środowisku.',
'source.defaultSource': 'Domyślny (lokalny socket)', 'source.saved': 'Źródło zapisane.', 'source.deleted': 'Źródło usunięte.',
'source.confirmDelete': 'Usunąć to źródło?', 'source.save': 'Zapisz',
}; };
const it = { const it = {
@ -602,6 +670,17 @@
'restore.confirmPrompt': 'Ripristina il backup selezionato per il profilo', 'restore.confirmPrompt': 'Ripristina il backup selezionato per il profilo',
'scope.volumes': 'solo volumi', 'scope.container': 'container completo', 'scope.volumes': 'solo volumi', 'scope.container': 'container completo',
'status.completed': 'Completato', 'status.partial': 'Parziale', 'status.error': 'Errore', 'status.running': 'In esecuzione', 'status.completed': 'Completato', 'status.partial': 'Parziale', 'status.error': 'Errore', 'status.running': 'In esecuzione',
'nav.source': 'Sorgenti',
'source.title': 'Sorgenti', 'source.new': '+ Nuova sorgente', 'source.empty': 'Nessuna sorgente configurata.',
'source.newSource': 'Nuova sorgente', 'source.name': 'Nome', 'source.namePlaceholder': 'Server remoto 1',
'source.type': 'Tipo di connessione', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'Connessione diretta', 'source.typeDirectDesc': 'Porta TCP 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'Tramite agente remoto',
'source.host': 'Host', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'Porta',
'source.socketUnavailable': 'Socket Unix non disponibile in questo ambiente.',
'source.defaultSource': 'Predefinito (socket locale)', 'source.saved': 'Sorgente salvata.', 'source.deleted': 'Sorgente rimossa.',
'source.confirmDelete': 'Eliminare questa sorgente?', 'source.save': 'Salva',
}; };
const ru = { const ru = {
@ -685,6 +764,17 @@
'restore.confirmPrompt': 'Восстановить выбранную копию для профиля', 'restore.confirmPrompt': 'Восстановить выбранную копию для профиля',
'scope.volumes': 'только тома', 'scope.container': 'весь контейнер', 'scope.volumes': 'только тома', 'scope.container': 'весь контейнер',
'status.completed': 'Завершено', 'status.partial': 'Частично', 'status.error': 'Ошибка', 'status.running': 'Выполняется', 'status.completed': 'Завершено', 'status.partial': 'Частично', 'status.error': 'Ошибка', 'status.running': 'Выполняется',
'nav.source': 'Источники',
'source.title': 'Источники', 'source.new': '+ Новый источник', 'source.empty': 'Источники не настроены.',
'source.newSource': 'Новый источник', 'source.name': 'Название', 'source.namePlaceholder': 'Удалённый сервер 1',
'source.type': 'Тип подключения', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'Прямое подключение', 'source.typeDirectDesc': 'TCP порт 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'Через удалённый агент',
'source.host': 'Хост', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'Порт',
'source.socketUnavailable': 'Unix Socket недоступен в этой среде.',
'source.defaultSource': 'По умолчанию (локальный сокет)', 'source.saved': 'Источник сохранён.', 'source.deleted': 'Источник удалён.',
'source.confirmDelete': 'Удалить этот источник?', 'source.save': 'Сохранить',
}; };
const zh = { const zh = {
@ -767,6 +857,17 @@
'restore.confirmPrompt': '恢复所选备份到配置', 'restore.confirmPrompt': '恢复所选备份到配置',
'scope.volumes': '仅卷', 'scope.container': '整个容器', 'scope.volumes': '仅卷', 'scope.container': '整个容器',
'status.completed': '已完成', 'status.partial': '部分完成', 'status.error': '错误', 'status.running': '运行中', 'status.completed': '已完成', 'status.partial': '部分完成', 'status.error': '错误', 'status.running': '运行中',
'nav.source': '来源',
'source.title': '来源', 'source.new': '+ 新来源', 'source.empty': '没有配置来源。',
'source.newSource': '新来源', 'source.name': '名称', 'source.namePlaceholder': '远程服务器 1',
'source.type': '连接类型', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': '直接连接', 'source.typeDirectDesc': 'TCP 端口 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': '通过远程代理',
'source.host': '主机', 'source.hostPlaceholder': '192.168.1.100', 'source.port': '端口',
'source.socketUnavailable': '此环境中 Unix Socket 不可用。',
'source.defaultSource': '默认(本地 Socket', 'source.saved': '来源已保存。', 'source.deleted': '来源已删除。',
'source.confirmDelete': '删除此来源?', 'source.save': '保存',
}; };
const ja = { const ja = {
@ -850,6 +951,17 @@
'restore.confirmPrompt': 'プロファイルの選択したバックアップを復元', 'restore.confirmPrompt': 'プロファイルの選択したバックアップを復元',
'scope.volumes': 'ボリュームのみ', 'scope.container': 'コンテナ全体', 'scope.volumes': 'ボリュームのみ', 'scope.container': 'コンテナ全体',
'status.completed': '完了', 'status.partial': '部分的', 'status.error': 'エラー', 'status.running': '実行中', 'status.completed': '完了', 'status.partial': '部分的', 'status.error': 'エラー', 'status.running': '実行中',
'nav.source': 'ソース',
'source.title': 'ソース', 'source.new': '+ 新しいソース', 'source.empty': 'ソースが設定されていません。',
'source.newSource': '新しいソース', 'source.name': '名前', 'source.namePlaceholder': 'リモートサーバー 1',
'source.type': '接続タイプ', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': '直接接続', 'source.typeDirectDesc': 'TCP ポート 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'リモートエージェント経由',
'source.host': 'ホスト', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'ポート',
'source.socketUnavailable': 'この環境では Unix Socket は使用できません。',
'source.defaultSource': 'デフォルト(ローカルソケット)', 'source.saved': 'ソースが保存されました。', 'source.deleted': 'ソースが削除されました。',
'source.confirmDelete': 'このソースを削除しますか?', 'source.save': '保存',
}; };
const fa = { const fa = {
@ -933,6 +1045,17 @@
'restore.confirmPrompt': 'بازیابی پشتیبان انتخاب‌شده برای پروفایل', 'restore.confirmPrompt': 'بازیابی پشتیبان انتخاب‌شده برای پروفایل',
'scope.volumes': 'فقط والیوم‌ها', 'scope.container': 'کل کانتینر', 'scope.volumes': 'فقط والیوم‌ها', 'scope.container': 'کل کانتینر',
'status.completed': 'تکمیل شد', 'status.partial': 'ناقص', 'status.error': 'خطا', 'status.running': 'در حال اجرا', 'status.completed': 'تکمیل شد', 'status.partial': 'ناقص', 'status.error': 'خطا', 'status.running': 'در حال اجرا',
'nav.source': 'منابع',
'source.title': 'منابع', 'source.new': '+ منبع جدید', 'source.empty': 'هیچ منبعی پیکربندی نشده.',
'source.newSource': 'منبع جدید', 'source.name': 'نام', 'source.namePlaceholder': 'سرور راه دور ۱',
'source.type': 'نوع اتصال', 'source.typeUnixSocket': 'Unix Socket', 'source.typeUnixSocketDesc': '/var/run/docker.sock',
'source.typeDirect': 'اتصال مستقیم', 'source.typeDirectDesc': 'پورت TCP 2375',
'source.typeAgent': 'Docker Agent', 'source.typeAgentDesc': 'از طریق عامل راه دور',
'source.host': 'هاست', 'source.hostPlaceholder': '192.168.1.100', 'source.port': 'پورت',
'source.socketUnavailable': 'Unix Socket در این محیط در دسترس نیست.',
'source.defaultSource': 'پیش‌فرض (سوکت محلی)', 'source.saved': 'منبع ذخیره شد.', 'source.deleted': 'منبع حذف شد.',
'source.confirmDelete': 'این منبع حذف شود؟', 'source.save': 'ذخیره',
}; };
window.TRANSLATIONS = { 'pt-BR': ptBR, en, es, de, pl, it, ru, zh, ja, fa }; window.TRANSLATIONS = { 'pt-BR': ptBR, en, es, de, pl, it, ru, zh, ja, fa };

View File

@ -59,8 +59,12 @@ function shellQuote(value) {
} }
class DockerService { class DockerService {
constructor({ socketPath, helperImage }) { constructor({ socketPath, host, port, helperImage }) {
this.docker = new Docker({ socketPath }); if (host) {
this.docker = new Docker({ host, port: port || 2375, protocol: 'http' });
} else {
this.docker = new Docker({ socketPath: socketPath || '/var/run/docker.sock' });
}
this.helperImage = helperImage; this.helperImage = helperImage;
this.runningInContainer = detectRunningInContainer(); this.runningInContainer = detectRunningInContainer();
this._selfMounts = null; // cache dos mounts do próprio container this._selfMounts = null; // cache dos mounts do próprio container

View File

@ -31,6 +31,22 @@ function computeNextRunAt(scheduledAt, frequency) {
return next; return next;
} }
function createDockerServiceForSource(source) {
if (!source) return null;
if (source.type === 'unix-socket') {
return new DockerService({
socketPath: source.socketPath || config.dockerSocketPath,
helperImage: config.helperImage,
});
}
// direct or agent — both connect via TCP
return new DockerService({
host: source.host,
port: source.port || 2375,
helperImage: config.helperImage,
});
}
async function main() { async function main() {
await fs.mkdir(config.dataDir, { recursive: true }); await fs.mkdir(config.dataDir, { recursive: true });
@ -191,9 +207,14 @@ async function main() {
} }
}); });
app.get('/api/containers', authMiddleware, async (_request, response) => { app.get('/api/containers', authMiddleware, async (request, response) => {
try { try {
const containers = await dockerService.listContainers(); let ds = dockerService;
if (request.query.sourceId) {
const source = await store.getSource(String(request.query.sourceId));
if (source) ds = createDockerServiceForSource(source);
}
const containers = await ds.listContainers();
response.json(containers); response.json(containers);
} catch (error) { } catch (error) {
response.status(500).json({ error: error.message }); response.status(500).json({ error: error.message });
@ -263,6 +284,7 @@ async function main() {
name: payload.name.trim(), name: payload.name.trim(),
backupDir: resolvedBackupDir.trim(), backupDir: resolvedBackupDir.trim(),
storageLocationId: payload.storageLocationId || existing?.storageLocationId || null, storageLocationId: payload.storageLocationId || existing?.storageLocationId || null,
sourceId: payload.sourceId || existing?.sourceId || null,
containerIds: payload.containerIds, containerIds: payload.containerIds,
mode: existing?.mode || 'full', mode: existing?.mode || 'full',
backupScope: payload.backupScope || existing?.backupScope || 'volumes', backupScope: payload.backupScope || existing?.backupScope || 'volumes',
@ -365,7 +387,20 @@ async function main() {
runJobs.set(runId, job); runJobs.set(runId, job);
void backupService.runProfile(profileId, { let runBackupService = backupService;
try {
const runProfile = await store.getProfile(profileId);
if (runProfile?.sourceId) {
const source = await store.getSource(runProfile.sourceId);
if (source) {
runBackupService = new BackupService({ dockerService: createDockerServiceForSource(source), store });
}
}
} catch {
// Fall back to default backupService
}
void runBackupService.runProfile(profileId, {
mode: requestedMode, mode: requestedMode,
basedOnFullBackupId, basedOnFullBackupId,
onProgress: (progressSnapshot) => { onProgress: (progressSnapshot) => {
@ -456,7 +491,20 @@ async function main() {
runJobs.set(runId, job); runJobs.set(runId, job);
void backupService.restoreBackup(profileId, request.body.backupId, { let restoreBackupService = backupService;
try {
const restoreProfile = await store.getProfile(profileId);
if (restoreProfile?.sourceId) {
const source = await store.getSource(restoreProfile.sourceId);
if (source) {
restoreBackupService = new BackupService({ dockerService: createDockerServiceForSource(source), store });
}
}
} catch {
// Fall back to default backupService
}
void restoreBackupService.restoreBackup(profileId, request.body.backupId, {
selectedContainerIds, selectedContainerIds,
onProgress: (progressSnapshot) => { onProgress: (progressSnapshot) => {
const currentJob = runJobs.get(runId); const currentJob = runJobs.get(runId);
@ -594,6 +642,107 @@ async function main() {
} }
}); });
// ─── Source routes ────────────────────────────────────
app.get('/api/sources/check-unix-socket', authMiddleware, async (_request, response) => {
try {
const socketPath = config.dockerSocketPath;
await fs.access(socketPath);
const testDs = new DockerService({ socketPath, helperImage: config.helperImage });
await testDs.docker.ping();
response.json({ available: true, socketPath });
} catch {
response.json({ available: false, socketPath: config.dockerSocketPath });
}
});
app.get('/api/sources', authMiddleware, async (_request, response) => {
try {
const sources = await store.listSources();
response.json(sources);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.post('/api/sources', authMiddleware, async (request, response) => {
try {
const payload = request.body || {};
if (!payload.name) {
return response.status(400).json({ error: 'Informe o nome da origem.' });
}
if (!['unix-socket', 'direct', 'agent'].includes(payload.type)) {
return response.status(400).json({ error: 'Tipo de origem inválido.' });
}
if ((payload.type === 'direct' || payload.type === 'agent') && !payload.host) {
return response.status(400).json({ error: 'Informe o host para este tipo de origem.' });
}
const existing = payload.id ? await store.getSource(payload.id) : null;
const source = await store.saveSource({
id: payload.id,
createdAt: existing?.createdAt,
name: payload.name.trim(),
type: payload.type,
socketPath: payload.socketPath || null,
host: payload.host?.trim() || null,
port: payload.port ? Number(payload.port) : null,
});
response.status(payload.id ? 200 : 201).json(source);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.get('/api/sources/:id/impact', authMiddleware, async (request, response) => {
try {
const impact = await store.sourceImpact(request.params.id);
response.json({
profileCount: impact.profiles.length,
profileNames: impact.profiles.map((p) => p.name),
backupCount: impact.backupCount,
});
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.delete('/api/sources/:id', authMiddleware, async (request, response) => {
try {
const sourceId = request.params.id;
const impact = await store.sourceImpact(sourceId);
const slugifyLocal = (value) =>
value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item';
for (const profile of impact.profiles) {
const backups = await store.listBackups(profile.id);
const deletedDirs = new Set();
for (const backup of backups) {
const backupRoot = backup.backupDir;
if (!backupRoot) continue;
for (const container of backup.containers || []) {
if (container.archiveRelativePath) {
await fs.rm(path.join(backupRoot, container.archiveRelativePath), { force: true });
}
}
if (profile.name) {
deletedDirs.add(path.join(backupRoot, slugifyLocal(profile.name)));
}
}
if (profile.backupDir) {
deletedDirs.add(path.join(profile.backupDir, slugifyLocal(profile.name)));
}
for (const dir of deletedDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
}
await store.deleteSource(sourceId);
response.status(204).end();
} catch (error) {
response.status(500).json({ error: error.message });
}
});
// ─── Schedule routes ────────────────────────────────── // ─── Schedule routes ──────────────────────────────────
app.get('/api/schedules', authMiddleware, async (_request, response) => { app.get('/api/schedules', authMiddleware, async (_request, response) => {
try { try {

View File

@ -24,6 +24,7 @@ class JsonStore {
parsed.backups ||= []; parsed.backups ||= [];
parsed.storageLocations ||= []; parsed.storageLocations ||= [];
parsed.schedules ||= []; parsed.schedules ||= [];
parsed.sources ||= [];
parsed.settings ||= {}; parsed.settings ||= {};
return parsed; return parsed;
} }
@ -60,6 +61,7 @@ class JsonStore {
volumeSelections: profileInput.volumeSelections || {}, volumeSelections: profileInput.volumeSelections || {},
backupDir: profileInput.backupDir, backupDir: profileInput.backupDir,
storageLocationId: profileInput.storageLocationId || null, storageLocationId: profileInput.storageLocationId || null,
sourceId: profileInput.sourceId || null,
updatedAt: now, updatedAt: now,
createdAt: profileInput.createdAt || now, createdAt: profileInput.createdAt || now,
}; };
@ -258,6 +260,63 @@ class JsonStore {
}); });
} }
async listSources() {
const data = await this.read();
return data.sources;
}
async getSource(sourceId) {
const data = await this.read();
return data.sources.find((s) => s.id === sourceId) || null;
}
async saveSource(input) {
const now = new Date().toISOString();
const source = {
id: input.id || randomUUID(),
name: input.name,
type: input.type,
socketPath: input.socketPath || null,
host: input.host || null,
port: input.port ? Number(input.port) : null,
updatedAt: now,
createdAt: input.createdAt || now,
};
await this.write((data) => {
data.sources ||= [];
const index = data.sources.findIndex((item) => item.id === source.id);
if (index >= 0) {
data.sources[index] = source;
} else {
data.sources.push(source);
}
return data;
});
return source;
}
async sourceImpact(sourceId) {
const data = await this.read();
const profiles = data.profiles.filter((p) => p.sourceId === sourceId);
const profileIds = new Set(profiles.map((p) => p.id));
const backupCount = data.backups.filter((b) => profileIds.has(b.profileId)).length;
return { profiles, backupCount };
}
async deleteSource(sourceId) {
await this.write((data) => {
const profileIds = new Set(
data.profiles.filter((p) => p.sourceId === sourceId).map((p) => p.id),
);
data.backups = data.backups.filter((b) => !profileIds.has(b.profileId));
data.profiles = data.profiles.filter((p) => !profileIds.has(p.id));
data.sources = (data.sources || []).filter((item) => item.id !== sourceId);
return data;
});
}
async getLastContainerBackupTime(profileId, containerId) { async getLastContainerBackupTime(profileId, containerId) {
const backups = await this.listBackups(profileId); const backups = await this.listBackups(profileId);
const ordered = backups const ordered = backups