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
+280
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');
+94
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>