atualiza versão para 0.2.1; adiciona suporte a novos tipos de armazenamento (FTP, SFTP, WebDAV, Google Drive) e aprimora a interface de gerenciamento de locais de armazenamento
This commit is contained in:
parent
6401559237
commit
fd72520f99
18
README.md
18
README.md
|
|
@ -9,7 +9,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/VERSION-0.2.0-blue?style=flat-square" />
|
<img src="https://img.shields.io/badge/VERSION-0.2.1-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" />
|
||||||
|
|
@ -18,11 +18,23 @@
|
||||||
|
|
||||||
> ⚠️ **AVISO CRÍTICO:** Aplicação em estágio inicial de desenvolvimento. Não use em produção — há risco de perda de dados.
|
> ⚠️ **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: **0.1.4**
|
Versão atual: **0.2.1**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## <20> Changelog### [0.2.0] — 2026-05-11
|
## 📋 Changelog
|
||||||
|
|
||||||
|
### [0.2.1] — 2026-05-12
|
||||||
|
|
||||||
|
#### Adicionado
|
||||||
|
- **Novos tipos de Storage Location:** além do armazenamento local, agora é possível cadastrar destinos remotos: **FTP**, **SFTP**, **WebDAV** e **Google Drive**.
|
||||||
|
- **Campos dinâmicos por tipo de storage:** o modal de criação/edição de Storage Location exibe apenas os campos relevantes ao tipo selecionado (host, porta, usuário, senha, caminho remoto, modo passivo, chave privada SSH, URL WebDAV, credenciais OAuth Google Drive).
|
||||||
|
- **Badge de tipo na listagem:** cada Storage Location exibe um badge colorido com seu tipo (Local, FTP, SFTP, WebDAV, Google Drive) e o endereço de destino na tabela.
|
||||||
|
- **Dropdown de profile atualizado:** o seletor de Storage Location nos Backup Profiles passa a exibir o tipo de cada location junto ao nome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [0.2.0] — 2026-05-11
|
||||||
|
|
||||||
#### Adicionado
|
#### 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
|
- **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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dockerbackup-app",
|
"name": "dockerbackup-app",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"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": {
|
||||||
|
|
|
||||||
148
public/app.js
148
public/app.js
|
|
@ -95,6 +95,22 @@ 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'),
|
||||||
|
// storage type fields
|
||||||
|
storageHost: document.querySelector('#storageHost'),
|
||||||
|
storagePort: document.querySelector('#storagePort'),
|
||||||
|
storageUsername: document.querySelector('#storageUsername'),
|
||||||
|
storagePassword: document.querySelector('#storagePassword'),
|
||||||
|
storageRemotePath: document.querySelector('#storageRemotePath'),
|
||||||
|
storagePassive: document.querySelector('#storagePassive'),
|
||||||
|
storagePrivateKey: document.querySelector('#storagePrivateKey'),
|
||||||
|
storageWebdavUrl: document.querySelector('#storageWebdavUrl'),
|
||||||
|
storageWebdavUsername: document.querySelector('#storageWebdavUsername'),
|
||||||
|
storageWebdavPassword: document.querySelector('#storageWebdavPassword'),
|
||||||
|
storageWebdavRemotePath: document.querySelector('#storageWebdavRemotePath'),
|
||||||
|
storageGdriveClientId: document.querySelector('#storageGdriveClientId'),
|
||||||
|
storageGdriveClientSecret: document.querySelector('#storageGdriveClientSecret'),
|
||||||
|
storageGdriveRefreshToken: document.querySelector('#storageGdriveRefreshToken'),
|
||||||
|
storageGdriveFolderId: document.querySelector('#storageGdriveFolderId'),
|
||||||
sourceFormModal: document.querySelector('#sourceFormModal'),
|
sourceFormModal: document.querySelector('#sourceFormModal'),
|
||||||
sourceForm: document.querySelector('#sourceForm'),
|
sourceForm: document.querySelector('#sourceForm'),
|
||||||
sourceFormId: document.querySelector('#sourceFormId'),
|
sourceFormId: document.querySelector('#sourceFormId'),
|
||||||
|
|
@ -196,14 +212,39 @@ function renderStorageLocationsList() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_TYPE_LABELS = {
|
||||||
|
'local': 'Local',
|
||||||
|
'ftp': 'FTP',
|
||||||
|
'sftp': 'SFTP',
|
||||||
|
'webdav': 'WebDAV',
|
||||||
|
'google-drive': 'Google Drive',
|
||||||
|
};
|
||||||
|
|
||||||
|
function storageLocationSummary(loc) {
|
||||||
|
const type = loc.type || 'local';
|
||||||
|
if (type === 'local') return `<code>${escapeHtml(loc.directory || '')}</code>`;
|
||||||
|
if (type === 'ftp' || type === 'sftp') {
|
||||||
|
const host = loc.host ? escapeHtml(loc.host) : '—';
|
||||||
|
const port = loc.port ? `:${loc.port}` : '';
|
||||||
|
const path = loc.remotePath ? escapeHtml(loc.remotePath) : '';
|
||||||
|
return `<code>${host}${port}${path ? '/' + path.replace(/^\//, '') : ''}</code>`;
|
||||||
|
}
|
||||||
|
if (type === 'webdav') return `<code>${escapeHtml(loc.url || '')}</code>`;
|
||||||
|
if (type === 'google-drive') {
|
||||||
|
return loc.folderId ? `<code>folder: ${escapeHtml(loc.folderId)}</code>` : '<code>Drive raiz</code>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
list.innerHTML = `
|
list.innerHTML = `
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>Nome</th><th>Diretório</th><th>Ações</th></tr></thead>
|
<thead><tr><th>Nome</th><th>Tipo</th><th>Destino</th><th>Ações</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${state.storageLocations.map((loc) => `
|
${state.storageLocations.map((loc) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${escapeHtml(loc.name)}</strong></td>
|
<td><strong>${escapeHtml(loc.name)}</strong></td>
|
||||||
<td><code>${escapeHtml(loc.directory)}</code></td>
|
<td><span class="storage-type-badge">${escapeHtml(STORAGE_TYPE_LABELS[loc.type || 'local'] || loc.type || 'local')}</span></td>
|
||||||
|
<td>${storageLocationSummary(loc)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn--ghost btn--sm" data-storage-action="delete" data-storage-id="${escapeHtml(loc.id)}">Excluir</button>
|
<button class="btn btn--ghost btn--sm" data-storage-action="delete" data-storage-id="${escapeHtml(loc.id)}">Excluir</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -218,17 +259,63 @@ function populateStorageLocationDropdown() {
|
||||||
const select = elements.storageLocationSelect;
|
const select = elements.storageLocationSelect;
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
const current = select.value;
|
const current = select.value;
|
||||||
|
const STORAGE_TYPE_LABELS = { 'local': 'Local', 'ftp': 'FTP', 'sftp': 'SFTP', 'webdav': 'WebDAV', 'google-drive': 'Google Drive' };
|
||||||
select.innerHTML = '<option value="">Selecione um local...</option>' +
|
select.innerHTML = '<option value="">Selecione um local...</option>' +
|
||||||
state.storageLocations.map((loc) =>
|
state.storageLocations.map((loc) => {
|
||||||
`<option value="${escapeHtml(loc.id)}">${escapeHtml(loc.name)} — ${escapeHtml(loc.directory)}</option>`
|
const typeLabel = STORAGE_TYPE_LABELS[loc.type || 'local'] || loc.type || 'local';
|
||||||
).join('');
|
const detail = (loc.type === 'local' || !loc.type) ? (loc.directory || '') :
|
||||||
|
(loc.host || loc.url || loc.clientId || '');
|
||||||
|
return `<option value="${escapeHtml(loc.id)}">[${escapeHtml(typeLabel)}] ${escapeHtml(loc.name)}${detail ? ' — ' + escapeHtml(detail) : ''}</option>`;
|
||||||
|
}).join('');
|
||||||
if (current) select.value = current;
|
if (current) select.value = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateStorageTypeFields(type) {
|
||||||
|
const sections = {
|
||||||
|
local: document.querySelector('#storageFieldsLocal'),
|
||||||
|
ftpSftp: document.querySelector('#storageFieldsFtpSftp'),
|
||||||
|
ftpOnly: document.querySelector('#storageFieldsFtpOnly'),
|
||||||
|
sftpOnly: document.querySelector('#storageFieldsSftpOnly'),
|
||||||
|
webdav: document.querySelector('#storageFieldsWebdav'),
|
||||||
|
gdrive: document.querySelector('#storageFieldsGdrive'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = (el) => el?.classList.remove('hidden');
|
||||||
|
const hide = (el) => el?.classList.add('hidden');
|
||||||
|
|
||||||
|
hide(sections.local);
|
||||||
|
hide(sections.ftpSftp);
|
||||||
|
hide(sections.ftpOnly);
|
||||||
|
hide(sections.sftpOnly);
|
||||||
|
hide(sections.webdav);
|
||||||
|
hide(sections.gdrive);
|
||||||
|
|
||||||
|
if (type === 'local') {
|
||||||
|
show(sections.local);
|
||||||
|
// Update port placeholder when switching to local doesn't apply
|
||||||
|
} else if (type === 'ftp') {
|
||||||
|
show(sections.ftpSftp);
|
||||||
|
show(sections.ftpOnly);
|
||||||
|
if (elements.storagePort && !elements.storagePort.value) elements.storagePort.placeholder = '21';
|
||||||
|
} else if (type === 'sftp') {
|
||||||
|
show(sections.ftpSftp);
|
||||||
|
show(sections.sftpOnly);
|
||||||
|
if (elements.storagePort && !elements.storagePort.value) elements.storagePort.placeholder = '22';
|
||||||
|
} else if (type === 'webdav') {
|
||||||
|
show(sections.webdav);
|
||||||
|
} else if (type === 'google-drive') {
|
||||||
|
show(sections.gdrive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openStorageModal() {
|
function openStorageModal() {
|
||||||
document.querySelector('#storageModalTitle').textContent = 'Novo Local de Armazenamento';
|
document.querySelector('#storageModalTitle').textContent = 'Novo Local de Armazenamento';
|
||||||
elements.storageLocationForm.reset();
|
elements.storageLocationForm.reset();
|
||||||
elements.storageLocationIdField.value = '';
|
elements.storageLocationIdField.value = '';
|
||||||
|
// Reset to local type
|
||||||
|
const localRadio = elements.storageLocationForm.querySelector('input[name="storageType"][value="local"]');
|
||||||
|
if (localRadio) localRadio.checked = true;
|
||||||
|
updateStorageTypeFields('local');
|
||||||
elements.storageLocationFormModal.classList.remove('hidden');
|
elements.storageLocationFormModal.classList.remove('hidden');
|
||||||
elements.storageLocationFormModal.setAttribute('aria-hidden', 'false');
|
elements.storageLocationFormModal.setAttribute('aria-hidden', 'false');
|
||||||
}
|
}
|
||||||
|
|
@ -240,12 +327,58 @@ function closeStorageModal() {
|
||||||
|
|
||||||
async function saveStorageLocation(event) {
|
async function saveStorageLocation(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = elements.storageLocationForm;
|
||||||
|
const selectedTypeInput = form.querySelector('input[name="storageType"]:checked');
|
||||||
|
const type = selectedTypeInput ? selectedTypeInput.value : 'local';
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
id: elements.storageLocationIdField.value || undefined,
|
id: elements.storageLocationIdField.value || undefined,
|
||||||
name: elements.storageLocationName.value.trim(),
|
name: elements.storageLocationName.value.trim(),
|
||||||
directory: elements.storageLocationDir.value.trim(),
|
type,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (type === 'local') {
|
||||||
|
payload.directory = elements.storageLocationDir.value.trim();
|
||||||
|
if (!payload.directory) {
|
||||||
|
showToast('Informe o diretório para armazenamento local.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (type === 'ftp' || type === 'sftp') {
|
||||||
|
payload.host = elements.storageHost.value.trim();
|
||||||
|
payload.port = elements.storagePort.value ? Number(elements.storagePort.value) : (type === 'ftp' ? 21 : 22);
|
||||||
|
payload.username = elements.storageUsername.value.trim();
|
||||||
|
payload.password = elements.storagePassword.value;
|
||||||
|
payload.remotePath = elements.storageRemotePath.value.trim();
|
||||||
|
if (!payload.host || !payload.username) {
|
||||||
|
showToast('Informe host e usuário para ' + type.toUpperCase() + '.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'ftp') {
|
||||||
|
payload.passive = elements.storagePassive.checked;
|
||||||
|
} else {
|
||||||
|
payload.privateKey = elements.storagePrivateKey.value;
|
||||||
|
}
|
||||||
|
} else if (type === 'webdav') {
|
||||||
|
payload.url = elements.storageWebdavUrl.value.trim();
|
||||||
|
payload.username = elements.storageWebdavUsername.value.trim();
|
||||||
|
payload.password = elements.storageWebdavPassword.value;
|
||||||
|
payload.remotePath = elements.storageWebdavRemotePath.value.trim();
|
||||||
|
if (!payload.url) {
|
||||||
|
showToast('Informe a URL do servidor WebDAV.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (type === 'google-drive') {
|
||||||
|
payload.clientId = elements.storageGdriveClientId.value.trim();
|
||||||
|
payload.clientSecret = elements.storageGdriveClientSecret.value;
|
||||||
|
payload.refreshToken = elements.storageGdriveRefreshToken.value;
|
||||||
|
payload.folderId = elements.storageGdriveFolderId.value.trim();
|
||||||
|
if (!payload.clientId || !payload.clientSecret || !payload.refreshToken) {
|
||||||
|
showToast('Informe Client ID, Client Secret e Refresh Token para Google Drive.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api('/api/storage-locations', {
|
await api('/api/storage-locations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -265,6 +398,9 @@ document.querySelector('#storageModalClose')?.addEventListener('click', closeSto
|
||||||
elements.storageLocationFormModal?.addEventListener('click', (e) => {
|
elements.storageLocationFormModal?.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('[data-action="close-storage-modal"]')) closeStorageModal();
|
if (e.target.closest('[data-action="close-storage-modal"]')) closeStorageModal();
|
||||||
});
|
});
|
||||||
|
elements.storageLocationFormModal?.addEventListener('change', (e) => {
|
||||||
|
if (e.target.name === 'storageType') updateStorageTypeFields(e.target.value);
|
||||||
|
});
|
||||||
elements.storageLocationForm?.addEventListener('submit', saveStorageLocation);
|
elements.storageLocationForm?.addEventListener('submit', saveStorageLocation);
|
||||||
elements.storageLocationsList?.addEventListener('click', async (e) => {
|
elements.storageLocationsList?.addEventListener('click', async (e) => {
|
||||||
const btn = e.target.closest('[data-storage-action="delete"]');
|
const btn = e.target.closest('[data-storage-action="delete"]');
|
||||||
|
|
|
||||||
|
|
@ -538,26 +538,152 @@
|
||||||
<!-- 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>
|
||||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="storageModalTitle">
|
<div class="modal-card modal-card--wide" role="dialog" aria-modal="true" aria-labelledby="storageModalTitle">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="storageModalTitle">Novo Local de Armazenamento</h3>
|
<h3 id="storageModalTitle">Novo Local de Armazenamento</h3>
|
||||||
<button id="storageModalClose" class="btn btn--ghost btn--sm" type="button" data-action="close-storage-modal">Fechar</button>
|
<button id="storageModalClose" class="btn btn--ghost btn--sm" type="button" data-action="close-storage-modal">Fechar</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="storageLocationForm" class="profile-form">
|
<form id="storageLocationForm" class="profile-form">
|
||||||
<input type="hidden" id="storageFormId" />
|
<input type="hidden" id="storageFormId" />
|
||||||
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="storageLocationName">Nome</label>
|
<label for="storageLocationName">Nome</label>
|
||||||
<input id="storageLocationName" type="text" placeholder="Backup principal" required />
|
<input id="storageLocationName" type="text" placeholder="Backup principal" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipo de storage -->
|
||||||
|
<fieldset class="form-fieldset">
|
||||||
|
<legend>Tipo de storage</legend>
|
||||||
|
<div class="radio-grid">
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="storageType" value="local" checked />
|
||||||
|
<span>Local</span>
|
||||||
|
<small>Diretório no servidor</small>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="storageType" value="ftp" />
|
||||||
|
<span>FTP</span>
|
||||||
|
<small>File Transfer Protocol</small>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="storageType" value="sftp" />
|
||||||
|
<span>SFTP</span>
|
||||||
|
<small>SSH File Transfer</small>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="storageType" value="webdav" />
|
||||||
|
<span>WebDAV</span>
|
||||||
|
<small>Web Distributed Authoring</small>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="storageType" value="google-drive" />
|
||||||
|
<span>Google Drive</span>
|
||||||
|
<small>Google Drive API v3</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Local: diretório -->
|
||||||
|
<div id="storageFieldsLocal">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="storageLocationDir">Diretório</label>
|
<label for="storageLocationDir">Diretório</label>
|
||||||
<div class="dir-input-group">
|
<div class="dir-input-group">
|
||||||
<input id="storageLocationDir" type="text" placeholder="/srv/docker-backups" required />
|
<input id="storageLocationDir" type="text" placeholder="/srv/docker-backups" />
|
||||||
<button type="button" id="browseDirBtn" class="btn btn--secondary btn--sm dir-browse-btn" title="Procurar diretório">
|
<button type="button" id="browseDirBtn" class="btn btn--secondary btn--sm dir-browse-btn" title="Procurar diretório">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP / SFTP: campos comuns -->
|
||||||
|
<div id="storageFieldsFtpSftp" class="hidden">
|
||||||
|
<div class="form-row-2col">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageHost">Host</label>
|
||||||
|
<input id="storageHost" type="text" placeholder="ftp.exemplo.com" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field form-field--port">
|
||||||
|
<label for="storagePort">Porta</label>
|
||||||
|
<input id="storagePort" type="number" min="1" max="65535" placeholder="21" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row-2col" style="grid-template-columns:1fr 1fr">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageUsername">Usuário</label>
|
||||||
|
<input id="storageUsername" type="text" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storagePassword">Senha</label>
|
||||||
|
<input id="storagePassword" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageRemotePath">Caminho remoto</label>
|
||||||
|
<input id="storageRemotePath" type="text" placeholder="/backups" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP apenas: modo passivo -->
|
||||||
|
<div id="storageFieldsFtpOnly" class="hidden">
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" id="storagePassive" />
|
||||||
|
<span>Modo passivo (PASV)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SFTP apenas: chave privada -->
|
||||||
|
<div id="storageFieldsSftpOnly" class="hidden">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storagePrivateKey">Chave privada SSH <small>(opcional — alternativa à senha)</small></label>
|
||||||
|
<textarea id="storagePrivateKey" rows="5" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WebDAV -->
|
||||||
|
<div id="storageFieldsWebdav" class="hidden">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageWebdavUrl">URL do servidor WebDAV</label>
|
||||||
|
<input id="storageWebdavUrl" type="url" placeholder="https://cloud.exemplo.com/remote.php/webdav" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row-2col" style="grid-template-columns:1fr 1fr">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageWebdavUsername">Usuário</label>
|
||||||
|
<input id="storageWebdavUsername" type="text" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageWebdavPassword">Senha</label>
|
||||||
|
<input id="storageWebdavPassword" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageWebdavRemotePath">Caminho remoto <small>(opcional)</small></label>
|
||||||
|
<input id="storageWebdavRemotePath" type="text" placeholder="/Backups/Docker" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google Drive -->
|
||||||
|
<div id="storageFieldsGdrive" class="hidden">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageGdriveClientId">Client ID</label>
|
||||||
|
<input id="storageGdriveClientId" type="text" autocomplete="off" placeholder="xxxx.apps.googleusercontent.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageGdriveClientSecret">Client Secret</label>
|
||||||
|
<input id="storageGdriveClientSecret" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageGdriveRefreshToken">Refresh Token</label>
|
||||||
|
<input id="storageGdriveRefreshToken" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="storageGdriveFolderId">ID da pasta de destino <small>(opcional)</small></label>
|
||||||
|
<input id="storageGdriveFolderId" type="text" placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn--primary" type="submit">Salvar</button>
|
<button class="btn btn--primary" type="submit">Salvar</button>
|
||||||
<button class="btn btn--secondary" type="button" id="cancelStorageForm">Cancelar</button>
|
<button class="btn btn--secondary" type="button" id="cancelStorageForm">Cancelar</button>
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,8 @@ button, input, select {
|
||||||
|
|
||||||
.form-field input[type='text'],
|
.form-field input[type='text'],
|
||||||
.form-field input[type='password'],
|
.form-field input[type='password'],
|
||||||
|
.form-field input[type='number'],
|
||||||
|
.form-field input[type='url'],
|
||||||
.form-field input[type='datetime-local'],
|
.form-field input[type='datetime-local'],
|
||||||
.form-field select,
|
.form-field select,
|
||||||
.form-fieldset input[type='text'] {
|
.form-fieldset input[type='text'] {
|
||||||
|
|
@ -555,6 +557,8 @@ button, input, select {
|
||||||
|
|
||||||
.form-field input[type='text'],
|
.form-field input[type='text'],
|
||||||
.form-field input[type='password'],
|
.form-field input[type='password'],
|
||||||
|
.form-field input[type='number'],
|
||||||
|
.form-field input[type='url'],
|
||||||
.form-field input[type='datetime-local'] {
|
.form-field input[type='datetime-local'] {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
|
|
@ -676,6 +680,62 @@ button, input, select {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row-2col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row-2col .form-field--port {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 150ms;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field .checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field .checkbox-row input[type='checkbox'] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: var(--accent-muted, #e8f0fe);
|
||||||
|
color: var(--accent, #4a6cf7);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Profiles list --------------------------------------- */
|
/* --- Profiles list --------------------------------------- */
|
||||||
.profiles-list {
|
.profiles-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,10 @@ async function main() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedBackupDir = loc.directory;
|
resolvedBackupDir = loc.directory;
|
||||||
|
if (!resolvedBackupDir && loc.type && loc.type !== 'local') {
|
||||||
|
response.status(400).json({ error: 'O tipo de armazenamento selecionado (remoto) ainda não suporta execução de backup. Utilize um local do tipo "Local".' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolvedBackupDir) {
|
if (!resolvedBackupDir) {
|
||||||
|
|
@ -552,14 +556,55 @@ async function main() {
|
||||||
app.post('/api/storage-locations', authMiddleware, async (request, response) => {
|
app.post('/api/storage-locations', authMiddleware, async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const payload = request.body || {};
|
const payload = request.body || {};
|
||||||
if (!payload.name || !payload.directory) {
|
if (!payload.name) {
|
||||||
response.status(400).json({ error: 'Informe nome e diretorio do local de armazenamento.' });
|
response.status(400).json({ error: 'Informe o nome do local de armazenamento.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const type = payload.type || 'local';
|
||||||
|
const validTypes = ['local', 'ftp', 'sftp', 'webdav', 'google-drive'];
|
||||||
|
if (!validTypes.includes(type)) {
|
||||||
|
response.status(400).json({ error: 'Tipo de armazenamento inválido.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'local' && !payload.directory) {
|
||||||
|
response.status(400).json({ error: 'Informe o diretório para armazenamento local.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((type === 'ftp' || type === 'sftp') && (!payload.host || !payload.username)) {
|
||||||
|
response.status(400).json({ error: 'Informe host e usuário para FTP/SFTP.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'webdav' && !payload.url) {
|
||||||
|
response.status(400).json({ error: 'Informe a URL do servidor WebDAV.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'google-drive' && (!payload.clientId || !payload.clientSecret || !payload.refreshToken)) {
|
||||||
|
response.status(400).json({ error: 'Informe Client ID, Client Secret e Refresh Token para Google Drive.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = payload.id ? await store.listStorageLocations().then((l) => l.find((x) => x.id === payload.id)) : null;
|
||||||
|
|
||||||
const location = await store.saveStorageLocation({
|
const location = await store.saveStorageLocation({
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
name: payload.name.trim(),
|
name: payload.name.trim(),
|
||||||
directory: payload.directory.trim(),
|
createdAt: existing?.createdAt,
|
||||||
|
type,
|
||||||
|
directory: payload.directory?.trim() || null,
|
||||||
|
host: payload.host?.trim() || null,
|
||||||
|
port: payload.port ? Number(payload.port) : null,
|
||||||
|
username: payload.username?.trim() || null,
|
||||||
|
password: payload.password || null,
|
||||||
|
remotePath: payload.remotePath?.trim() || null,
|
||||||
|
passive: payload.passive !== undefined ? Boolean(payload.passive) : null,
|
||||||
|
privateKey: payload.privateKey || null,
|
||||||
|
url: payload.url?.trim() || null,
|
||||||
|
clientId: payload.clientId?.trim() || null,
|
||||||
|
clientSecret: payload.clientSecret || null,
|
||||||
|
refreshToken: payload.refreshToken || null,
|
||||||
|
folderId: payload.folderId?.trim() || null,
|
||||||
});
|
});
|
||||||
response.status(payload.id ? 200 : 201).json(location);
|
response.status(payload.id ? 200 : 201).json(location);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
21
src/store.js
21
src/store.js
|
|
@ -152,7 +152,26 @@ class JsonStore {
|
||||||
const location = {
|
const location = {
|
||||||
id: input.id || randomUUID(),
|
id: input.id || randomUUID(),
|
||||||
name: input.name,
|
name: input.name,
|
||||||
directory: input.directory,
|
type: input.type || 'local',
|
||||||
|
// local
|
||||||
|
directory: input.directory || null,
|
||||||
|
// ftp / sftp
|
||||||
|
host: input.host || null,
|
||||||
|
port: input.port != null ? Number(input.port) : null,
|
||||||
|
username: input.username || null,
|
||||||
|
password: input.password || null,
|
||||||
|
remotePath: input.remotePath || null,
|
||||||
|
// ftp only
|
||||||
|
passive: input.passive != null ? Boolean(input.passive) : null,
|
||||||
|
// sftp only
|
||||||
|
privateKey: input.privateKey || null,
|
||||||
|
// webdav
|
||||||
|
url: input.url || null,
|
||||||
|
// google-drive
|
||||||
|
clientId: input.clientId || null,
|
||||||
|
clientSecret: input.clientSecret || null,
|
||||||
|
refreshToken: input.refreshToken || null,
|
||||||
|
folderId: input.folderId || null,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdAt: input.createdAt || now,
|
createdAt: input.createdAt || now,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue