atualiza versão para 0.1.0; adiciona funcionalidade de agendamentos de backup com suporte a execução única, diária, semanal e mensal
This commit is contained in:
parent
44331e8b87
commit
c5b19bb2d5
13
README.md
13
README.md
|
|
@ -9,7 +9,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/VERSION-0.0.9-blue?style=flat-square" />
|
<img src="https://img.shields.io/badge/VERSION-0.1.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" />
|
||||||
|
|
@ -18,12 +18,21 @@
|
||||||
|
|
||||||
> ⚠️ **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.0.9**
|
Versão atual: **0.1.0**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## <20> Changelog
|
## <20> Changelog
|
||||||
|
### [0.1.0] — 2026-05-09
|
||||||
|
|
||||||
|
#### Adicionado
|
||||||
|
- **Aba Agendamentos:** nova aba que permite criar e gerenciar agendamentos de backup, com suporte a execução única, diária, semanal e mensal.
|
||||||
|
- **Formulário de agendamento:** o usuário informa o profile, o tipo de backup (full ou incremental), a frequência, a data/hora de início e se o agendamento está ativo. Para backups incrementais, é possível escolher o backup full base ou usar "Auto" (mais recente disponível).
|
||||||
|
- **Scheduler no backend:** loop que roda a cada 60 segundos e dispara automaticamente os backups agendados no horário correto, calculando a próxima execução após cada run recorrente. Agendamentos do tipo "única vez" são desativados automaticamente após execução.
|
||||||
|
- **API de agendamentos:** novas rotas `GET /api/schedules`, `POST /api/schedules`, `PATCH /api/schedules/:id/toggle` e `DELETE /api/schedules/:id`.
|
||||||
|
- **Toggle ativo/inativo:** o agendamento pode ser pausado ou reativado diretamente na tabela sem precisar editar o formulário.
|
||||||
|
|
||||||
|
---
|
||||||
### [0.0.9] — 2026-05-09
|
### [0.0.9] — 2026-05-09
|
||||||
|
|
||||||
#### Corrigido
|
#### Corrigido
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dockerbackup-app",
|
"name": "dockerbackup-app",
|
||||||
"version": "0.0.9",
|
"version": "0.1.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": {
|
||||||
|
|
|
||||||
280
public/app.js
280
public/app.js
|
|
@ -55,6 +55,7 @@ const state = {
|
||||||
containers: [],
|
containers: [],
|
||||||
profiles: [],
|
profiles: [],
|
||||||
storageLocations: [],
|
storageLocations: [],
|
||||||
|
schedules: [],
|
||||||
activeRuns: new Map(),
|
activeRuns: new Map(),
|
||||||
volumeSelections: {},
|
volumeSelections: {},
|
||||||
};
|
};
|
||||||
|
|
@ -117,6 +118,9 @@ function navigateTo(viewName) {
|
||||||
if (viewName === 'backups') {
|
if (viewName === 'backups') {
|
||||||
renderBackupsView();
|
renderBackupsView();
|
||||||
}
|
}
|
||||||
|
if (viewName === 'schedules') {
|
||||||
|
loadSchedules();
|
||||||
|
}
|
||||||
if (viewName === 'storage') {
|
if (viewName === 'storage') {
|
||||||
loadStorageLocations();
|
loadStorageLocations();
|
||||||
}
|
}
|
||||||
|
|
@ -1386,6 +1390,282 @@ async function handleProfileAction(event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Schedules ────────────────────────────────────────────
|
||||||
|
const FREQUENCY_LABELS = {
|
||||||
|
once: 'Única vez',
|
||||||
|
daily: 'Diária',
|
||||||
|
weekly: 'Semanal',
|
||||||
|
monthly: 'Mensal',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadSchedules() {
|
||||||
|
try {
|
||||||
|
state.schedules = await api('/api/schedules');
|
||||||
|
renderSchedulesList();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchedulesList() {
|
||||||
|
const list = document.querySelector('#schedulesList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (!state.schedules.length) {
|
||||||
|
list.innerHTML = '<p class="empty-state">Nenhum agendamento configurado. Crie um para automatizar seus backups.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = `
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Profile</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Frequência</th>
|
||||||
|
<th>Próxima Execução</th>
|
||||||
|
<th>Última Execução</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Ativo</th>
|
||||||
|
<th>Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${state.schedules.map((schedule) => {
|
||||||
|
const profile = state.profiles.find((p) => p.id === schedule.profileId);
|
||||||
|
const profileName = profile ? profile.name : '—';
|
||||||
|
let nextRun = '—';
|
||||||
|
if (schedule.frequency === 'once' && schedule.lastRunAt && !schedule.nextRunAt) {
|
||||||
|
nextRun = 'Concluído';
|
||||||
|
} else if (!schedule.enabled) {
|
||||||
|
nextRun = 'Pausado';
|
||||||
|
} else if (schedule.nextRunAt) {
|
||||||
|
nextRun = new Date(schedule.nextRunAt).toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
const lastRun = schedule.lastRunAt ? new Date(schedule.lastRunAt).toLocaleString('pt-BR') : 'Nunca';
|
||||||
|
const statusBadge = schedule.lastRunStatus
|
||||||
|
? `<span class="status-badge status-badge--${escapeHtml(schedule.lastRunStatus === 'ok' ? 'ok' : 'error')}">${escapeHtml(schedule.lastRunStatus)}</span>`
|
||||||
|
: '—';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(schedule.name || '—')}</strong></td>
|
||||||
|
<td>${escapeHtml(profileName)}</td>
|
||||||
|
<td><span class="badge badge--${escapeHtml(schedule.backupMode || 'full')}">${escapeHtml(schedule.backupMode === 'incremental' ? 'Incremental' : 'Full')}</span></td>
|
||||||
|
<td>${escapeHtml(FREQUENCY_LABELS[schedule.frequency] || schedule.frequency)}</td>
|
||||||
|
<td>${escapeHtml(nextRun)}</td>
|
||||||
|
<td>${escapeHtml(lastRun)}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td style="text-align:center">
|
||||||
|
<input type="checkbox" class="schedule-toggle" data-schedule-id="${escapeHtml(schedule.id)}" ${schedule.enabled ? 'checked' : ''} title="${schedule.enabled ? 'Pausar' : 'Ativar'}" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn--secondary btn--sm" data-schedule-action="edit" data-schedule-id="${escapeHtml(schedule.id)}">Editar</button>
|
||||||
|
<button class="btn btn--danger btn--sm" data-schedule-action="delete" data-schedule-id="${escapeHtml(schedule.id)}">Excluir</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScheduleModal(schedule = null) {
|
||||||
|
const title = document.querySelector('#scheduleModalTitle');
|
||||||
|
const form = document.querySelector('#scheduleForm');
|
||||||
|
if (!form || !title) return;
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
document.querySelector('#scheduleId').value = '';
|
||||||
|
document.querySelector('#scheduleFullBackupField').classList.add('hidden');
|
||||||
|
document.querySelector('#scheduleBasedOnFullBackupId').innerHTML =
|
||||||
|
'<option value="">Auto (usar o mais recente disponível)</option>';
|
||||||
|
|
||||||
|
const profileSelect = document.querySelector('#scheduleProfileId');
|
||||||
|
profileSelect.innerHTML = '<option value="">Selecione um profile...</option>' +
|
||||||
|
state.profiles.map((p) =>
|
||||||
|
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const defaultDate = new Date();
|
||||||
|
defaultDate.setHours(defaultDate.getHours() + 1, 0, 0, 0);
|
||||||
|
document.querySelector('#scheduleDateTime').value = defaultDate.toISOString().slice(0, 16);
|
||||||
|
document.querySelector('#scheduleEnabled').checked = true;
|
||||||
|
|
||||||
|
if (schedule) {
|
||||||
|
title.textContent = 'Editar Agendamento';
|
||||||
|
document.querySelector('#scheduleId').value = schedule.id;
|
||||||
|
document.querySelector('#scheduleName').value = schedule.name || '';
|
||||||
|
profileSelect.value = schedule.profileId;
|
||||||
|
|
||||||
|
const modeRadio = document.querySelector(`input[name="scheduleBackupMode"][value="${schedule.backupMode || 'full'}"]`);
|
||||||
|
if (modeRadio) modeRadio.checked = true;
|
||||||
|
|
||||||
|
document.querySelector('#scheduleFrequency').value = schedule.frequency || 'daily';
|
||||||
|
document.querySelector('#scheduleEnabled').checked = schedule.enabled !== false;
|
||||||
|
|
||||||
|
if (schedule.scheduledAt) {
|
||||||
|
document.querySelector('#scheduleDateTime').value = schedule.scheduledAt.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedule.backupMode === 'incremental') {
|
||||||
|
document.querySelector('#scheduleFullBackupField').classList.remove('hidden');
|
||||||
|
await loadFullBackupsForSchedule(schedule.profileId, schedule.basedOnFullBackupId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Novo Agendamento';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('#scheduleFormModal').classList.remove('hidden');
|
||||||
|
document.querySelector('#scheduleFormModal').setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScheduleModal() {
|
||||||
|
document.querySelector('#scheduleFormModal').classList.add('hidden');
|
||||||
|
document.querySelector('#scheduleFormModal').setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFullBackupsForSchedule(profileId, selectedId = null) {
|
||||||
|
const select = document.querySelector('#scheduleBasedOnFullBackupId');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
select.innerHTML = '<option value="">Auto (usar o mais recente disponível)</option>';
|
||||||
|
if (!profileId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backups = await api(`/api/profiles/${profileId}/backups`);
|
||||||
|
const fullBackups = backups
|
||||||
|
.filter((b) => b.mode === 'full' && (b.status === 'ok' || b.status === 'partial'))
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
|
||||||
|
for (const b of fullBackups) {
|
||||||
|
const label = `${new Date(b.createdAt).toLocaleString('pt-BR')} · ${(b.containers || []).map((c) => c.containerName).join(', ')} · ${b.status}`;
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = b.id;
|
||||||
|
option.textContent = label;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedId) select.value = selectedId;
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('#schedulesList')?.addEventListener('change', async (e) => {
|
||||||
|
const checkbox = e.target.closest('.schedule-toggle');
|
||||||
|
if (!checkbox) return;
|
||||||
|
|
||||||
|
const scheduleId = checkbox.dataset.scheduleId;
|
||||||
|
try {
|
||||||
|
await api(`/api/schedules/${scheduleId}/toggle`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ enabled: checkbox.checked }),
|
||||||
|
});
|
||||||
|
showToast(checkbox.checked ? 'Agendamento ativado.' : 'Agendamento pausado.');
|
||||||
|
await loadSchedules();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#schedulesList')?.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('[data-schedule-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const { scheduleAction, scheduleId } = btn.dataset;
|
||||||
|
const schedule = state.schedules.find((s) => s.id === scheduleId);
|
||||||
|
if (!schedule) return;
|
||||||
|
|
||||||
|
if (scheduleAction === 'delete') {
|
||||||
|
if (!window.confirm(`Excluir o agendamento "${schedule.name || 'sem nome'}"?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/schedules/${scheduleId}`, { method: 'DELETE' });
|
||||||
|
await loadSchedules();
|
||||||
|
showToast('Agendamento removido.');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
} else if (scheduleAction === 'edit') {
|
||||||
|
if (!state.profiles.length) await loadProfiles();
|
||||||
|
await openScheduleModal(schedule);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#openCreateScheduleModal')?.addEventListener('click', async () => {
|
||||||
|
if (!state.profiles.length) await loadProfiles();
|
||||||
|
await openScheduleModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#scheduleModalClose')?.addEventListener('click', closeScheduleModal);
|
||||||
|
document.querySelector('#cancelScheduleForm')?.addEventListener('click', closeScheduleModal);
|
||||||
|
document.querySelector('#scheduleFormModal')?.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-action="close-schedule-modal"]')) closeScheduleModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#scheduleProfileId')?.addEventListener('change', async (e) => {
|
||||||
|
const mode = document.querySelector('input[name="scheduleBackupMode"]:checked')?.value;
|
||||||
|
if (mode === 'incremental') {
|
||||||
|
await loadFullBackupsForSchedule(e.target.value, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="scheduleBackupMode"]').forEach((radio) => {
|
||||||
|
radio.addEventListener('change', async (e) => {
|
||||||
|
const fullField = document.querySelector('#scheduleFullBackupField');
|
||||||
|
if (e.target.value === 'incremental') {
|
||||||
|
fullField.classList.remove('hidden');
|
||||||
|
const profileId = document.querySelector('#scheduleProfileId').value;
|
||||||
|
await loadFullBackupsForSchedule(profileId, null);
|
||||||
|
} else {
|
||||||
|
fullField.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#scheduleForm')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const profileId = document.querySelector('#scheduleProfileId').value;
|
||||||
|
if (!profileId) {
|
||||||
|
showToast('Selecione um profile.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTimeValue = document.querySelector('#scheduleDateTime').value;
|
||||||
|
if (!dateTimeValue) {
|
||||||
|
showToast('Informe a data e hora de início.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupMode = document.querySelector('input[name="scheduleBackupMode"]:checked')?.value || 'full';
|
||||||
|
const basedOnFullBackupId = backupMode === 'incremental'
|
||||||
|
? (document.querySelector('#scheduleBasedOnFullBackupId').value || null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: document.querySelector('#scheduleId').value || undefined,
|
||||||
|
name: document.querySelector('#scheduleName').value.trim(),
|
||||||
|
profileId,
|
||||||
|
backupMode,
|
||||||
|
basedOnFullBackupId,
|
||||||
|
frequency: document.querySelector('#scheduleFrequency').value,
|
||||||
|
scheduledAt: new Date(dateTimeValue).toISOString(),
|
||||||
|
enabled: document.querySelector('#scheduleEnabled').checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/schedules', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
closeScheduleModal();
|
||||||
|
await loadSchedules();
|
||||||
|
showToast('Agendamento salvo.');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Login overlay ────────────────────────────────────────
|
// ─── Login overlay ────────────────────────────────────────
|
||||||
function showLoginOverlay() {
|
function showLoginOverlay() {
|
||||||
const overlay = document.querySelector('#loginOverlay');
|
const overlay = document.querySelector('#loginOverlay');
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@
|
||||||
<span data-i18n="nav.backups">Backups</span>
|
<span data-i18n="nav.backups">Backups</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="nav-item" data-view="schedules">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
<span>Agendamentos</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button class="nav-item" data-view="settings">
|
<button class="nav-item" data-view="settings">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
|
@ -205,6 +211,17 @@
|
||||||
<div id="backupsViewList"></div>
|
<div id="backupsViewList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW: Schedules -->
|
||||||
|
<div id="view-schedules" class="view hidden">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Agendamentos</h1>
|
||||||
|
<button id="openCreateScheduleModal" class="btn btn--primary">+ Novo Agendamento</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div id="schedulesList"></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">
|
||||||
|
|
@ -485,6 +502,83 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Criar/Editar Agendamento -->
|
||||||
|
<div id="scheduleFormModal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" data-action="close-schedule-modal"></div>
|
||||||
|
<div class="modal-card modal-card--wide" role="dialog" aria-modal="true" aria-labelledby="scheduleModalTitle">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="scheduleModalTitle">Novo Agendamento</h3>
|
||||||
|
<button id="scheduleModalClose" class="btn btn--ghost btn--sm" type="button">Fechar</button>
|
||||||
|
</div>
|
||||||
|
<form id="scheduleForm" class="profile-form">
|
||||||
|
<input type="hidden" id="scheduleId" />
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="scheduleName">Nome do agendamento <small>(opcional)</small></label>
|
||||||
|
<input id="scheduleName" type="text" placeholder="Ex: Backup diário do banco" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="scheduleProfileId">Profile de backup</label>
|
||||||
|
<select id="scheduleProfileId" required>
|
||||||
|
<option value="">Selecione um profile...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="form-fieldset">
|
||||||
|
<legend>Tipo de backup</legend>
|
||||||
|
<div class="radio-grid">
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="scheduleBackupMode" value="full" checked />
|
||||||
|
<span>Full</span>
|
||||||
|
<small>Backup completo de todos os volumes</small>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="scheduleBackupMode" value="incremental" />
|
||||||
|
<span>Incremental</span>
|
||||||
|
<small>Apenas alterações desde o último full</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div id="scheduleFullBackupField" class="form-field hidden">
|
||||||
|
<label for="scheduleBasedOnFullBackupId">Backup full base</label>
|
||||||
|
<select id="scheduleBasedOnFullBackupId">
|
||||||
|
<option value="">Auto (usar o mais recente disponível)</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-hint">Para agendamentos recorrentes, recomenda-se "Auto".</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="scheduleFrequency">Frequência</label>
|
||||||
|
<select id="scheduleFrequency" required>
|
||||||
|
<option value="once">Única vez</option>
|
||||||
|
<option value="daily" selected>Diária</option>
|
||||||
|
<option value="weekly">Semanal</option>
|
||||||
|
<option value="monthly">Mensal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="scheduleDateTime">Data e hora do primeiro backup</label>
|
||||||
|
<input id="scheduleDateTime" type="datetime-local" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="scheduleEnabled" checked />
|
||||||
|
<span>Agendamento ativo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary" type="submit">Salvar agendamento</button>
|
||||||
|
<button class="btn btn--secondary" type="button" id="cancelScheduleForm">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/translations.js"></script>
|
<script src="/translations.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
197
src/server.js
197
src/server.js
|
|
@ -16,6 +16,21 @@ function hashPassword(password) {
|
||||||
return crypto.createHash('sha256').update(password).digest('hex');
|
return crypto.createHash('sha256').update(password).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeNextRunAt(scheduledAt, frequency) {
|
||||||
|
const base = new Date(scheduledAt);
|
||||||
|
if (frequency === 'once') return base;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let next = new Date(base);
|
||||||
|
while (next <= now) {
|
||||||
|
if (frequency === 'daily') next.setDate(next.getDate() + 1);
|
||||||
|
else if (frequency === 'weekly') next.setDate(next.getDate() + 7);
|
||||||
|
else if (frequency === 'monthly') next.setMonth(next.getMonth() + 1);
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await fs.mkdir(config.dataDir, { recursive: true });
|
await fs.mkdir(config.dataDir, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -566,6 +581,188 @@ async function main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Schedule routes ──────────────────────────────────
|
||||||
|
app.get('/api/schedules', authMiddleware, async (_request, response) => {
|
||||||
|
try {
|
||||||
|
const schedules = await store.listSchedules();
|
||||||
|
response.json(schedules);
|
||||||
|
} catch (error) {
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/schedules', authMiddleware, async (request, response) => {
|
||||||
|
try {
|
||||||
|
const payload = request.body || {};
|
||||||
|
|
||||||
|
if (!payload.profileId) {
|
||||||
|
return response.status(400).json({ error: 'Informe o profile.' });
|
||||||
|
}
|
||||||
|
if (!payload.scheduledAt) {
|
||||||
|
return response.status(400).json({ error: 'Informe a data/hora de início.' });
|
||||||
|
}
|
||||||
|
if (!['once', 'daily', 'weekly', 'monthly'].includes(payload.frequency)) {
|
||||||
|
return response.status(400).json({ error: 'Frequência inválida.' });
|
||||||
|
}
|
||||||
|
if (payload.backupMode && !['full', 'incremental'].includes(payload.backupMode)) {
|
||||||
|
return response.status(400).json({ error: 'Modo de backup inválido.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await store.getProfile(payload.profileId);
|
||||||
|
if (!profile) {
|
||||||
|
return response.status(400).json({ error: 'Profile não encontrado.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledAt = new Date(payload.scheduledAt).toISOString();
|
||||||
|
const nextRunAt = computeNextRunAt(scheduledAt, payload.frequency).toISOString();
|
||||||
|
const existing = payload.id ? await store.getSchedule(payload.id) : null;
|
||||||
|
|
||||||
|
const schedule = await store.saveSchedule({
|
||||||
|
id: payload.id,
|
||||||
|
createdAt: existing?.createdAt,
|
||||||
|
name: (payload.name || '').trim() || `${profile.name} — ${payload.frequency}`,
|
||||||
|
profileId: payload.profileId,
|
||||||
|
backupMode: payload.backupMode || 'full',
|
||||||
|
basedOnFullBackupId: payload.basedOnFullBackupId || null,
|
||||||
|
frequency: payload.frequency,
|
||||||
|
scheduledAt,
|
||||||
|
nextRunAt,
|
||||||
|
enabled: payload.enabled !== false,
|
||||||
|
lastRunAt: existing?.lastRunAt || null,
|
||||||
|
lastRunStatus: existing?.lastRunStatus || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.status(payload.id ? 200 : 201).json(schedule);
|
||||||
|
} catch (error) {
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/schedules/:id/toggle', authMiddleware, async (request, response) => {
|
||||||
|
try {
|
||||||
|
const schedule = await store.getSchedule(request.params.id);
|
||||||
|
if (!schedule) {
|
||||||
|
return response.status(404).json({ error: 'Agendamento não encontrado.' });
|
||||||
|
}
|
||||||
|
const enabled = request.body?.enabled !== undefined ? Boolean(request.body.enabled) : !schedule.enabled;
|
||||||
|
const updated = await store.saveSchedule({ ...schedule, enabled });
|
||||||
|
response.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/schedules/:id', authMiddleware, async (request, response) => {
|
||||||
|
try {
|
||||||
|
await store.deleteSchedule(request.params.id);
|
||||||
|
response.status(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
response.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Scheduler ────────────────────────────────────────
|
||||||
|
async function runScheduledJobs() {
|
||||||
|
let schedules;
|
||||||
|
try {
|
||||||
|
schedules = await store.listSchedules();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
if (!schedule.enabled || !schedule.nextRunAt) continue;
|
||||||
|
|
||||||
|
const nextRun = new Date(schedule.nextRunAt);
|
||||||
|
if (nextRun > now) continue;
|
||||||
|
|
||||||
|
const runningJob = [...runJobs.values()].find(
|
||||||
|
(job) => job.profileId === schedule.profileId && job.status === 'running',
|
||||||
|
);
|
||||||
|
if (runningJob) continue;
|
||||||
|
|
||||||
|
let resolvedFullBackupId = schedule.basedOnFullBackupId;
|
||||||
|
if (schedule.backupMode === 'incremental' && !resolvedFullBackupId) {
|
||||||
|
try {
|
||||||
|
const backups = await store.listBackups(schedule.profileId);
|
||||||
|
const fullBackups = backups
|
||||||
|
.filter((b) => b.mode === 'full' && (b.status === 'ok' || b.status === 'partial'))
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
if (!fullBackups.length) {
|
||||||
|
console.log(`[Scheduler] Pulando agendamento incremental "${schedule.name}" (${schedule.id}): nenhum backup full disponível`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolvedFullBackupId = fullBackups[0].id;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNextRunAt = schedule.frequency === 'once'
|
||||||
|
? null
|
||||||
|
: computeNextRunAt(schedule.scheduledAt, schedule.frequency).toISOString();
|
||||||
|
const newEnabled = schedule.frequency !== 'once';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.saveSchedule({
|
||||||
|
...schedule,
|
||||||
|
lastRunAt: now.toISOString(),
|
||||||
|
nextRunAt: newNextRunAt,
|
||||||
|
enabled: newEnabled,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = crypto.randomUUID();
|
||||||
|
runJobs.set(runId, {
|
||||||
|
id: runId,
|
||||||
|
profileId: schedule.profileId,
|
||||||
|
kind: 'backup',
|
||||||
|
scheduledBy: schedule.id,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: now.toISOString(),
|
||||||
|
progress: null,
|
||||||
|
result: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Executando agendamento "${schedule.name}" (${schedule.id})`);
|
||||||
|
|
||||||
|
void backupService.runProfile(schedule.profileId, {
|
||||||
|
mode: schedule.backupMode,
|
||||||
|
basedOnFullBackupId: resolvedFullBackupId,
|
||||||
|
}).then(async (backupRun) => {
|
||||||
|
const currentJob = runJobs.get(runId);
|
||||||
|
if (currentJob) {
|
||||||
|
currentJob.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors';
|
||||||
|
currentJob.result = backupRun;
|
||||||
|
currentJob.finishedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
const latestSchedule = await store.getSchedule(schedule.id);
|
||||||
|
if (latestSchedule) {
|
||||||
|
await store.saveSchedule({ ...latestSchedule, lastRunStatus: backupRun.status });
|
||||||
|
}
|
||||||
|
}).catch(async (err) => {
|
||||||
|
const currentJob = runJobs.get(runId);
|
||||||
|
if (currentJob) {
|
||||||
|
currentJob.status = 'error';
|
||||||
|
currentJob.error = err.message;
|
||||||
|
currentJob.finishedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
const latestSchedule = await store.getSchedule(schedule.id);
|
||||||
|
if (latestSchedule) {
|
||||||
|
await store.saveSchedule({ ...latestSchedule, lastRunStatus: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => runScheduledJobs().catch(console.error), 60_000);
|
||||||
|
setTimeout(() => runScheduledJobs().catch(console.error), 10_000);
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
|
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
50
src/store.js
50
src/store.js
|
|
@ -23,6 +23,7 @@ class JsonStore {
|
||||||
parsed.profiles ||= [];
|
parsed.profiles ||= [];
|
||||||
parsed.backups ||= [];
|
parsed.backups ||= [];
|
||||||
parsed.storageLocations ||= [];
|
parsed.storageLocations ||= [];
|
||||||
|
parsed.schedules ||= [];
|
||||||
parsed.settings ||= {};
|
parsed.settings ||= {};
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +209,55 @@ class JsonStore {
|
||||||
return this.getSettings();
|
return this.getSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSchedules() {
|
||||||
|
const data = await this.read();
|
||||||
|
return data.schedules;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSchedule(scheduleId) {
|
||||||
|
const data = await this.read();
|
||||||
|
return data.schedules.find((s) => s.id === scheduleId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSchedule(input) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const schedule = {
|
||||||
|
id: input.id || randomUUID(),
|
||||||
|
name: input.name || '',
|
||||||
|
profileId: input.profileId,
|
||||||
|
backupMode: input.backupMode || 'full',
|
||||||
|
basedOnFullBackupId: input.basedOnFullBackupId || null,
|
||||||
|
frequency: input.frequency || 'once',
|
||||||
|
scheduledAt: input.scheduledAt,
|
||||||
|
nextRunAt: input.nextRunAt !== undefined ? input.nextRunAt : (input.scheduledAt || null),
|
||||||
|
enabled: input.enabled !== undefined ? Boolean(input.enabled) : true,
|
||||||
|
lastRunAt: input.lastRunAt || null,
|
||||||
|
lastRunStatus: input.lastRunStatus || null,
|
||||||
|
createdAt: input.createdAt || now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.write((data) => {
|
||||||
|
data.schedules ||= [];
|
||||||
|
const index = data.schedules.findIndex((item) => item.id === schedule.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
data.schedules[index] = schedule;
|
||||||
|
} else {
|
||||||
|
data.schedules.push(schedule);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSchedule(scheduleId) {
|
||||||
|
await this.write((data) => {
|
||||||
|
data.schedules = (data.schedules || []).filter((s) => s.id !== scheduleId);
|
||||||
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue