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 = ` + + + + + + + + + + + + + + + + ${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 + ? `${escapeHtml(schedule.lastRunStatus)}` + : '—'; + return ` + + + + + + + + + + + + `; + }).join('')} + +
NomeProfileTipoFrequênciaPróxima ExecuçãoÚltima ExecuçãoStatusAtivoAções
${escapeHtml(schedule.name || '—')}${escapeHtml(profileName)}${escapeHtml(schedule.backupMode === 'incremental' ? 'Incremental' : 'Full')}${escapeHtml(FREQUENCY_LABELS[schedule.frequency] || schedule.frequency)}${escapeHtml(nextRun)}${escapeHtml(lastRun)}${statusBadge} + + + + +
+ `; +} + +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 = + ''; + + const profileSelect = document.querySelector('#scheduleProfileId'); + profileSelect.innerHTML = '' + + state.profiles.map((p) => + `` + ).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 = ''; + 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 +
  • + +
  • + +
    +
    +
    + + + + + diff --git a/src/server.js b/src/server.js index 2c706f8..ea3d32b 100644 --- a/src/server.js +++ b/src/server.js @@ -16,6 +16,21 @@ function hashPassword(password) { 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() { 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, () => { console.log(`Docker Backup app ouvindo na porta ${config.port}`); }); diff --git a/src/store.js b/src/store.js index 480ec57..4a438d4 100644 --- a/src/store.js +++ b/src/store.js @@ -23,6 +23,7 @@ class JsonStore { parsed.profiles ||= []; parsed.backups ||= []; parsed.storageLocations ||= []; + parsed.schedules ||= []; parsed.settings ||= {}; return parsed; } @@ -208,6 +209,55 @@ class JsonStore { 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) { const backups = await this.listBackups(profileId); const ordered = backups