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:
parent
a58ee6299a
commit
6401559237
|
|
@ -9,7 +9,7 @@
|
|||
</p>
|
||||
|
||||
<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/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<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
|
||||
|
||||
#### Corrigido
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dockerbackup-app",
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.0",
|
||||
"description": "Aplicacao web para backup e restauracao de volumes Docker",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
220
public/app.js
220
public/app.js
|
|
@ -56,6 +56,7 @@ const state = {
|
|||
profiles: [],
|
||||
storageLocations: [],
|
||||
schedules: [],
|
||||
sources: [],
|
||||
activeRuns: new Map(),
|
||||
volumeSelections: {},
|
||||
};
|
||||
|
|
@ -94,6 +95,14 @@ const elements = {
|
|||
storageLocationDir: document.querySelector('#storageLocationDir'),
|
||||
storageLocationIdField: document.querySelector('#storageFormId'),
|
||||
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 ──────────────────────────────────────
|
||||
|
|
@ -124,6 +133,9 @@ function navigateTo(viewName) {
|
|||
if (viewName === 'storage') {
|
||||
loadStorageLocations();
|
||||
}
|
||||
if (viewName === 'source') {
|
||||
loadSources();
|
||||
}
|
||||
if (viewName === 'settings') {
|
||||
loadSettingsView();
|
||||
}
|
||||
|
|
@ -156,6 +168,7 @@ function closeProfileModal() {
|
|||
document.querySelector('#openCreateProfileModal')?.addEventListener('click', () => {
|
||||
resetForm();
|
||||
populateStorageLocationDropdown();
|
||||
populateSourceDropdown();
|
||||
openProfileModal('Novo Profile');
|
||||
});
|
||||
|
||||
|
|
@ -288,8 +301,204 @@ elements.storageLocationsList?.addEventListener('click', async (e) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ─── Directory Browser Modal ──────────────────────────────
|
||||
let _dirBrowserCurrentPath = '/';
|
||||
// ─── Sources ──────────────────────────────────────────────
|
||||
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() {
|
||||
const initial = elements.storageLocationDir.value.trim() || '/';
|
||||
|
|
@ -1170,9 +1379,13 @@ function fillForm(profile) {
|
|||
elements.profileId.value = profile.id;
|
||||
elements.profileName.value = profile.name;
|
||||
populateStorageLocationDropdown();
|
||||
populateSourceDropdown();
|
||||
if (profile.storageLocationId) {
|
||||
elements.storageLocationSelect.value = profile.storageLocationId;
|
||||
}
|
||||
if (elements.profileSourceSelect) {
|
||||
elements.profileSourceSelect.value = profile.sourceId || '';
|
||||
}
|
||||
const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
|
||||
document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
|
||||
state.volumeSelections = Object.assign({}, profile.volumeSelections || {});
|
||||
|
|
@ -1220,6 +1433,7 @@ async function saveProfile(event) {
|
|||
id: elements.profileId.value || undefined,
|
||||
name: elements.profileName.value,
|
||||
storageLocationId,
|
||||
sourceId: elements.profileSourceSelect?.value || undefined,
|
||||
containerIds: selectedContainerIds,
|
||||
backupScope,
|
||||
volumeSelections,
|
||||
|
|
@ -1929,7 +2143,7 @@ document.querySelector('#aboutUpdateBtn')?.addEventListener('click', async () =>
|
|||
async function init() {
|
||||
applyTranslations();
|
||||
try {
|
||||
await Promise.all([loadContainers(), loadProfiles(), loadStorageLocations()]);
|
||||
await Promise.all([loadContainers(), loadProfiles(), loadStorageLocations(), loadSources()]);
|
||||
await updateDashboard();
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@
|
|||
<span>Dashboard</span>
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -223,6 +229,17 @@
|
|||
</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 -->
|
||||
<div id="view-storage" class="view hidden">
|
||||
<div class="page-header">
|
||||
|
|
@ -404,6 +421,13 @@
|
|||
</select>
|
||||
</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">
|
||||
<legend>Escopo do backup</legend>
|
||||
<div class="radio-grid">
|
||||
|
|
@ -452,6 +476,65 @@
|
|||
</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 -->
|
||||
<div id="storageLocationFormModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-backdrop" data-action="close-storage-modal"></div>
|
||||
|
|
|
|||
|
|
@ -175,6 +175,30 @@
|
|||
'status.partial': 'Partial',
|
||||
'status.error': 'Error',
|
||||
'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 = {
|
||||
|
|
@ -271,6 +295,17 @@
|
|||
'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',
|
||||
|
||||
'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 = {
|
||||
|
|
@ -354,6 +389,17 @@
|
|||
'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',
|
||||
|
||||
'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 = {
|
||||
|
|
@ -436,6 +482,17 @@
|
|||
'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',
|
||||
|
||||
'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 = {
|
||||
|
|
@ -519,6 +576,17 @@
|
|||
'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',
|
||||
|
||||
'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 = {
|
||||
|
|
@ -602,6 +670,17 @@
|
|||
'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',
|
||||
|
||||
'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 = {
|
||||
|
|
@ -685,6 +764,17 @@
|
|||
'restore.confirmPrompt': 'Восстановить выбранную копию для профиля',
|
||||
'scope.volumes': 'только тома', 'scope.container': 'весь контейнер',
|
||||
'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 = {
|
||||
|
|
@ -767,6 +857,17 @@
|
|||
'restore.confirmPrompt': '恢复所选备份到配置',
|
||||
'scope.volumes': '仅卷', 'scope.container': '整个容器',
|
||||
'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 = {
|
||||
|
|
@ -850,6 +951,17 @@
|
|||
'restore.confirmPrompt': 'プロファイルの選択したバックアップを復元',
|
||||
'scope.volumes': 'ボリュームのみ', 'scope.container': 'コンテナ全体',
|
||||
'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 = {
|
||||
|
|
@ -933,6 +1045,17 @@
|
|||
'restore.confirmPrompt': 'بازیابی پشتیبان انتخابشده برای پروفایل',
|
||||
'scope.volumes': 'فقط والیومها', 'scope.container': 'کل کانتینر',
|
||||
'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 };
|
||||
|
|
|
|||
|
|
@ -59,8 +59,12 @@ function shellQuote(value) {
|
|||
}
|
||||
|
||||
class DockerService {
|
||||
constructor({ socketPath, helperImage }) {
|
||||
this.docker = new Docker({ socketPath });
|
||||
constructor({ socketPath, host, port, helperImage }) {
|
||||
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.runningInContainer = detectRunningInContainer();
|
||||
this._selfMounts = null; // cache dos mounts do próprio container
|
||||
|
|
|
|||
157
src/server.js
157
src/server.js
|
|
@ -31,6 +31,22 @@ function computeNextRunAt(scheduledAt, frequency) {
|
|||
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() {
|
||||
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 {
|
||||
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);
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error.message });
|
||||
|
|
@ -263,6 +284,7 @@ async function main() {
|
|||
name: payload.name.trim(),
|
||||
backupDir: resolvedBackupDir.trim(),
|
||||
storageLocationId: payload.storageLocationId || existing?.storageLocationId || null,
|
||||
sourceId: payload.sourceId || existing?.sourceId || null,
|
||||
containerIds: payload.containerIds,
|
||||
mode: existing?.mode || 'full',
|
||||
backupScope: payload.backupScope || existing?.backupScope || 'volumes',
|
||||
|
|
@ -365,7 +387,20 @@ async function main() {
|
|||
|
||||
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,
|
||||
basedOnFullBackupId,
|
||||
onProgress: (progressSnapshot) => {
|
||||
|
|
@ -456,7 +491,20 @@ async function main() {
|
|||
|
||||
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,
|
||||
onProgress: (progressSnapshot) => {
|
||||
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 ──────────────────────────────────
|
||||
app.get('/api/schedules', authMiddleware, async (_request, response) => {
|
||||
try {
|
||||
|
|
|
|||
59
src/store.js
59
src/store.js
|
|
@ -24,6 +24,7 @@ class JsonStore {
|
|||
parsed.backups ||= [];
|
||||
parsed.storageLocations ||= [];
|
||||
parsed.schedules ||= [];
|
||||
parsed.sources ||= [];
|
||||
parsed.settings ||= {};
|
||||
return parsed;
|
||||
}
|
||||
|
|
@ -60,6 +61,7 @@ class JsonStore {
|
|||
volumeSelections: profileInput.volumeSelections || {},
|
||||
backupDir: profileInput.backupDir,
|
||||
storageLocationId: profileInput.storageLocationId || null,
|
||||
sourceId: profileInput.sourceId || null,
|
||||
updatedAt: 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) {
|
||||
const backups = await this.listBackups(profileId);
|
||||
const ordered = backups
|
||||
|
|
|
|||
Loading…
Reference in New Issue