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:
Alexander Sabino 2026-05-09 21:31:56 +01:00
parent 44331e8b87
commit c5b19bb2d5
6 changed files with 633 additions and 3 deletions

View File

@ -9,7 +9,7 @@
</p>
<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/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<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.
Versão atual: **0.0.9**
Versão atual: **0.1.0**
---
## <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
#### Corrigido

View File

@ -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": {

View File

@ -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 = '<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 ────────────────────────────────────────
function showLoginOverlay() {
const overlay = document.querySelector('#loginOverlay');

View File

@ -51,6 +51,12 @@
<span data-i18n="nav.backups">Backups</span>
</button>
</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>
<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>
@ -205,6 +211,17 @@
<div id="backupsViewList"></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 -->
<div id="view-storage" class="view hidden">
<div class="page-header">
@ -485,6 +502,83 @@
</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 &quot;Auto&quot;.</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="/app.js"></script>
</body>

View File

@ -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}`);
});

View File

@ -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