From c5b19bb2d5ea7dd51c3c466a5f2002d6b465d01f Mon Sep 17 00:00:00 2001
From: Alexander Sabino <32822107+asabino2@users.noreply.github.com>
Date: Sat, 9 May 2026 21:31:56 +0100
Subject: [PATCH] =?UTF-8?q?atualiza=20vers=C3=A3o=20para=200.1.0;=20adicio?=
=?UTF-8?q?na=20funcionalidade=20de=20agendamentos=20de=20backup=20com=20s?=
=?UTF-8?q?uporte=20a=20execu=C3=A7=C3=A3o=20=C3=BAnica,=20di=C3=A1ria,=20?=
=?UTF-8?q?semanal=20e=20mensal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 13 ++-
package.json | 2 +-
public/app.js | 280 ++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 94 ++++++++++++++++
src/server.js | 197 ++++++++++++++++++++++++++++++++
src/store.js | 50 +++++++++
6 files changed, 633 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index d3a20cf..39e952f 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -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.
-Versão atual: **0.0.9**
+Versão atual: **0.1.0**
---
## � 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
#### Corrigido
diff --git a/package.json b/package.json
index d438caf..0ae94af 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dockerbackup-app",
- "version": "0.0.9",
+ "version": "0.1.0",
"description": "Aplicacao web para backup e restauracao de volumes Docker",
"main": "src/server.js",
"scripts": {
diff --git a/public/app.js b/public/app.js
index 5d137bc..4e5c8a4 100644
--- a/public/app.js
+++ b/public/app.js
@@ -55,6 +55,7 @@ const state = {
containers: [],
profiles: [],
storageLocations: [],
+ schedules: [],
activeRuns: new Map(),
volumeSelections: {},
};
@@ -117,6 +118,9 @@ function navigateTo(viewName) {
if (viewName === 'backups') {
renderBackupsView();
}
+ if (viewName === 'schedules') {
+ loadSchedules();
+ }
if (viewName === 'storage') {
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 = '
Nenhum agendamento configurado. Crie um para automatizar seus backups.
';
+ return;
+ }
+
+ list.innerHTML = `
+
+ `;
+}
+
+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 =
+ 'Auto (usar o mais recente disponível) ';
+
+ const profileSelect = document.querySelector('#scheduleProfileId');
+ profileSelect.innerHTML = 'Selecione um profile... ' +
+ state.profiles.map((p) =>
+ `${escapeHtml(p.name)} `
+ ).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 = 'Auto (usar o mais recente disponível) ';
+ 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 ────────────────────────────────────────
function showLoginOverlay() {
const overlay = document.querySelector('#loginOverlay');
diff --git a/public/index.html b/public/index.html
index ec805c9..aa937b7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -51,6 +51,12 @@
Backups
+
+
+
+ Agendamentos
+
+
@@ -205,6 +211,17 @@
+
+
+
+
+
+