mirror of
https://github.com/asabino2/dockerbackup.git
synced 2026-09-24 06:36:59 +00:00
first commit
This commit is contained in:
+596
@@ -0,0 +1,596 @@
|
||||
const state = {
|
||||
containers: [],
|
||||
profiles: [],
|
||||
activeRuns: new Map(),
|
||||
};
|
||||
|
||||
const elements = {
|
||||
containerCount: document.querySelector('#containerCount'),
|
||||
profileCount: document.querySelector('#profileCount'),
|
||||
profileForm: document.querySelector('#profileForm'),
|
||||
profileId: document.querySelector('#profileId'),
|
||||
profileName: document.querySelector('#profileName'),
|
||||
backupDir: document.querySelector('#backupDir'),
|
||||
containerOptions: document.querySelector('#containerOptions'),
|
||||
profilesList: document.querySelector('#profilesList'),
|
||||
formModeBadge: document.querySelector('#formModeBadge'),
|
||||
toast: document.querySelector('#toast'),
|
||||
restoreModal: document.querySelector('#restoreModal'),
|
||||
restoreModalSubtitle: document.querySelector('#restoreModalSubtitle'),
|
||||
restoreContainerOptions: document.querySelector('#restoreContainerOptions'),
|
||||
restoreModalConfirm: document.querySelector('#restoreModalConfirm'),
|
||||
restoreModalClose: document.querySelector('#restoreModalClose'),
|
||||
restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'),
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Falha na requisicao');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function showToast(message, isError = false) {
|
||||
elements.toast.textContent = message;
|
||||
elements.toast.classList.remove('hidden', 'error');
|
||||
if (isError) {
|
||||
elements.toast.classList.add('error');
|
||||
}
|
||||
|
||||
window.clearTimeout(showToast.timer);
|
||||
showToast.timer = window.setTimeout(() => {
|
||||
elements.toast.classList.add('hidden');
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
function getSelectedContainerIds() {
|
||||
return [...document.querySelectorAll('input[name="containerIds"]:checked')].map((input) => input.value);
|
||||
}
|
||||
|
||||
function renderContainers() {
|
||||
elements.containerCount.textContent = String(state.containers.length);
|
||||
|
||||
if (!state.containers.length) {
|
||||
elements.containerOptions.innerHTML = '<p class="empty-state">Nenhum container encontrado.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = new Set(getSelectedContainerIds());
|
||||
elements.containerOptions.innerHTML = state.containers.map((container) => `
|
||||
<label class="container-option">
|
||||
<input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} />
|
||||
<span>
|
||||
<strong>${escapeHtml(container.name)}</strong>
|
||||
<small>${escapeHtml(container.image)} · ${escapeHtml(container.status)}</small>
|
||||
</span>
|
||||
<em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function backupButtons(profile) {
|
||||
const isRunning = state.activeRuns.has(profile.id);
|
||||
return `
|
||||
<div class="run-actions">
|
||||
<label class="mode-picker" for="runMode-${escapeHtml(profile.id)}">
|
||||
<span>Modo do backup</span>
|
||||
<select id="runMode-${escapeHtml(profile.id)}" data-run-mode="${escapeHtml(profile.id)}" class="mode-select" ${isRunning ? 'disabled' : ''}>
|
||||
<option value="full">Full</option>
|
||||
<option value="incremental">Incremental</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="card-actions">
|
||||
<button data-action="run" data-profile-id="${escapeHtml(profile.id)}" class="primary-button small" ${isRunning ? 'disabled' : ''}>${isRunning ? 'Executando...' : 'Run'}</button>
|
||||
<button data-action="edit" data-profile-id="${escapeHtml(profile.id)}" class="secondary-button small">Editar</button>
|
||||
<button data-action="delete" data-profile-id="${escapeHtml(profile.id)}" class="ghost-button small">Excluir</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getRunMode(profileId) {
|
||||
const selector = document.querySelector(`select[data-run-mode="${profileId}"]`);
|
||||
const value = selector?.value;
|
||||
return value === 'incremental' ? 'incremental' : 'full';
|
||||
}
|
||||
|
||||
function getProfileScopeLabel(scope) {
|
||||
return scope === 'container' ? 'container inteiro' : 'somente volumes';
|
||||
}
|
||||
|
||||
function askRestoreContainerSelection(profile, backup) {
|
||||
const restorable = (backup.containers || []).filter((item) => item.status === 'ok');
|
||||
if (!restorable.length) {
|
||||
throw new Error('Nao ha containers validos neste backup para restaurar.');
|
||||
}
|
||||
|
||||
elements.restoreModalSubtitle.textContent = `${profile.name} - ${new Date(backup.createdAt).toLocaleString('pt-BR')}`;
|
||||
elements.restoreContainerOptions.innerHTML = restorable.map((item) => `
|
||||
<label class="modal-option">
|
||||
<input type="checkbox" name="restoreContainerIds" value="${escapeHtml(item.containerId)}" checked />
|
||||
<span>
|
||||
<strong>${escapeHtml(item.containerName)}</strong>
|
||||
<small>${escapeHtml(item.status)}</small>
|
||||
</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
elements.restoreModal.classList.remove('hidden');
|
||||
elements.restoreModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const closeModal = () => {
|
||||
elements.restoreModal.classList.add('hidden');
|
||||
elements.restoreModal.setAttribute('aria-hidden', 'true');
|
||||
elements.restoreContainerOptions.innerHTML = '';
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
elements.restoreModalConfirm.removeEventListener('click', onConfirm);
|
||||
elements.restoreModalClose.removeEventListener('click', onCancel);
|
||||
elements.restoreModalSelectAll.removeEventListener('click', onSelectAll);
|
||||
elements.restoreModal.removeEventListener('click', onBackdropClick);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
const selected = [...elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]:checked')]
|
||||
.map((input) => input.value);
|
||||
|
||||
if (!selected.length) {
|
||||
showToast('Selecione ao menos um container para restaurar.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
closeModal();
|
||||
resolve(selected);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
cleanup();
|
||||
closeModal();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onSelectAll = () => {
|
||||
for (const input of elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]')) {
|
||||
input.checked = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onBackdropClick = (event) => {
|
||||
const closeTrigger = event.target.closest('[data-action="close-restore-modal"]');
|
||||
if (closeTrigger) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
elements.restoreModalConfirm.addEventListener('click', onConfirm);
|
||||
elements.restoreModalClose.addEventListener('click', onCancel);
|
||||
elements.restoreModalSelectAll.addEventListener('click', onSelectAll);
|
||||
elements.restoreModal.addEventListener('click', onBackdropClick);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBackupFailures(backup) {
|
||||
const failures = (backup.containers || []).filter((item) => item.status === 'error');
|
||||
if (!failures.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<small class="backup-error">
|
||||
Falhas: ${failures.map((item) => `${escapeHtml(item.containerName || item.containerId || 'container')}: ${escapeHtml(item.error || 'erro desconhecido')}`).join(' | ')}
|
||||
</small>
|
||||
`;
|
||||
}
|
||||
|
||||
function progressBar(percent) {
|
||||
const normalized = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
return `
|
||||
<div class="progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${normalized}">
|
||||
<span class="progress-fill" style="width: ${normalized}%;"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRunProgress(profileId) {
|
||||
const run = state.activeRuns.get(profileId);
|
||||
const host = document.querySelector(`[data-run-progress="${profileId}"]`);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run || !run.progress) {
|
||||
host.innerHTML = '';
|
||||
host.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const overall = run.progress.overall || { total: 0, completed: 0, pending: 0, percent: 0 };
|
||||
const currentContainer = run.progress.currentContainer;
|
||||
const file = currentContainer?.file || { current: 0, total: 0, percent: 0, currentFile: null };
|
||||
const containerPercent = Number.isFinite(currentContainer?.percent) ? currentContainer.percent : 0;
|
||||
const stepLabel = currentContainer?.step || 'aguardando';
|
||||
const stepMessage = currentContainer?.message || 'Aguardando processamento de arquivo...';
|
||||
const logs = Array.isArray(currentContainer?.logs) ? currentContainer.logs.slice(-8) : [];
|
||||
const operation = run?.kind === 'restore' || run?.progress?.operation === 'restore' ? 'restore' : 'backup';
|
||||
const operationTitle = operation === 'restore' ? 'Progresso do restore' : 'Progresso do backup';
|
||||
|
||||
host.classList.remove('hidden');
|
||||
host.innerHTML = `
|
||||
<div class="progress-card">
|
||||
<div class="progress-header">
|
||||
<strong>${escapeHtml(operationTitle)}</strong>
|
||||
<small>${escapeHtml(run.status)}</small>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Containers: ${escapeHtml(String(overall.completed))}/${escapeHtml(String(overall.total))} concluido(s)</span>
|
||||
<span>Faltam ${escapeHtml(String(overall.pending))}</span>
|
||||
</div>
|
||||
${progressBar(overall.percent)}
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Container atual: ${escapeHtml(currentContainer?.containerName || currentContainer?.containerId || '-')}</span>
|
||||
<span>${escapeHtml(String(Math.round(containerPercent)))}%</span>
|
||||
</div>
|
||||
${progressBar(containerPercent)}
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Arquivos: ${escapeHtml(String(file.current || 0))}/${escapeHtml(String(file.total || 0))}</span>
|
||||
<span>${escapeHtml(String(Math.round(file.percent || 0)))}%</span>
|
||||
</div>
|
||||
${progressBar(file.percent || 0)}
|
||||
<small class="current-file">Etapa: ${escapeHtml(stepLabel)} · ${escapeHtml(file.currentFile || stepMessage)}</small>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-label-row">
|
||||
<span>Log detalhado</span>
|
||||
<span>${escapeHtml(String(logs.length))} evento(s)</span>
|
||||
</div>
|
||||
<div class="progress-log">
|
||||
${logs.length
|
||||
? logs.map((line) => `<small>${escapeHtml(line)}</small>`).join('')
|
||||
: '<small>Nenhum evento detalhado ainda.</small>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAllRunProgress() {
|
||||
for (const profile of state.profiles) {
|
||||
renderRunProgress(profile.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollRun(profileId, runId) {
|
||||
const doneStatus = new Set(['completed', 'completed-with-errors', 'error']);
|
||||
|
||||
while (true) {
|
||||
const run = await api(`/api/runs/${runId}`);
|
||||
state.activeRuns.set(profileId, run);
|
||||
renderRunProgress(profileId);
|
||||
|
||||
if (doneStatus.has(run.status)) {
|
||||
return run;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, 700);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function restoreButtons(profile, backups) {
|
||||
if (!backups.length) {
|
||||
return '<p class="empty-inline">Nenhum backup executado ainda.</p>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="backup-history">
|
||||
${backups.map((backup) => `
|
||||
<button
|
||||
class="backup-item"
|
||||
data-action="restore"
|
||||
data-profile-id="${escapeHtml(profile.id)}"
|
||||
data-backup-id="${escapeHtml(backup.id)}"
|
||||
>
|
||||
<span>
|
||||
<strong>${escapeHtml(new Date(backup.createdAt).toLocaleString('pt-BR'))}</strong>
|
||||
<small>${escapeHtml(backup.mode)} · ${escapeHtml(getProfileScopeLabel(backup.backupScope))} · ${escapeHtml(backup.status)} · ${escapeHtml(backup.containers.map((item) => item.containerName).join(', '))}</small>
|
||||
${formatBackupFailures(backup)}
|
||||
</span>
|
||||
<em>Restore</em>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderProfiles() {
|
||||
elements.profileCount.textContent = String(state.profiles.length);
|
||||
|
||||
if (!state.profiles.length) {
|
||||
elements.profilesList.innerHTML = '<p class="empty-state">Nenhum profile salvo.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const backupsByProfile = await Promise.all(
|
||||
state.profiles.map(async (profile) => [profile.id, await api(`/api/profiles/${profile.id}/backups`)])
|
||||
);
|
||||
const backupMap = new Map(backupsByProfile);
|
||||
|
||||
elements.profilesList.innerHTML = state.profiles.map((profile) => `
|
||||
<article class="profile-card">
|
||||
<div class="profile-card-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(profile.name)}</h3>
|
||||
<p>${escapeHtml(String(profile.containerIds.length))} container(es) · ${escapeHtml(getProfileScopeLabel(profile.backupScope))}</p>
|
||||
<code>${escapeHtml(profile.backupDir)}</code>
|
||||
</div>
|
||||
${backupButtons(profile)}
|
||||
</div>
|
||||
<div class="chips">
|
||||
${profile.containerIds.map((containerId) => {
|
||||
const container = state.containers.find((item) => item.id === containerId);
|
||||
return `<span class="chip">${escapeHtml(container ? container.name : containerId.slice(0, 12))}</span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div class="run-progress hidden" data-run-progress="${escapeHtml(profile.id)}"></div>
|
||||
<div class="restore-block">
|
||||
<h4>Restaurar</h4>
|
||||
${restoreButtons(profile, backupMap.get(profile.id) || [])}
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
|
||||
renderAllRunProgress();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
elements.profileForm.reset();
|
||||
elements.profileId.value = '';
|
||||
elements.formModeBadge.textContent = 'criar';
|
||||
renderContainers();
|
||||
}
|
||||
|
||||
function fillForm(profile) {
|
||||
elements.profileId.value = profile.id;
|
||||
elements.profileName.value = profile.name;
|
||||
elements.backupDir.value = profile.backupDir;
|
||||
const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
|
||||
document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
|
||||
elements.formModeBadge.textContent = 'editar';
|
||||
renderContainers();
|
||||
for (const containerId of profile.containerIds) {
|
||||
const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`);
|
||||
if (input) {
|
||||
input.checked = true;
|
||||
}
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function loadContainers() {
|
||||
state.containers = await api('/api/containers');
|
||||
renderContainers();
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
state.profiles = await api('/api/profiles');
|
||||
await renderProfiles();
|
||||
}
|
||||
|
||||
async function saveProfile(event) {
|
||||
event.preventDefault();
|
||||
const payload = {
|
||||
id: elements.profileId.value || undefined,
|
||||
name: elements.profileName.value,
|
||||
backupDir: elements.backupDir.value,
|
||||
containerIds: getSelectedContainerIds(),
|
||||
backupScope: document.querySelector('input[name="backupScope"]:checked').value,
|
||||
};
|
||||
|
||||
try {
|
||||
await api('/api/profiles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadProfiles();
|
||||
resetForm();
|
||||
showToast('Profile salvo.');
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileAction(event) {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { action, profileId, backupId } = button.dataset;
|
||||
const profile = state.profiles.find((item) => item.id === profileId);
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'edit') {
|
||||
fillForm(profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!window.confirm(`Excluir o profile ${profile.name}?`)) {
|
||||
return;
|
||||
}
|
||||
await api(`/api/profiles/${profileId}`, { method: 'DELETE' });
|
||||
await loadProfiles();
|
||||
showToast('Profile removido.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'run') {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Executando...';
|
||||
const mode = getRunMode(profileId);
|
||||
|
||||
const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode }) });
|
||||
state.activeRuns.set(profileId, {
|
||||
id: start.runId,
|
||||
profileId,
|
||||
status: 'running',
|
||||
progress: {
|
||||
overall: { total: profile.containerIds.length, completed: 0, pending: profile.containerIds.length, percent: 0 },
|
||||
currentContainer: null,
|
||||
},
|
||||
});
|
||||
renderRunProgress(profileId);
|
||||
|
||||
const run = await pollRun(profileId, start.runId);
|
||||
state.activeRuns.delete(profileId);
|
||||
await loadProfiles();
|
||||
if (run.status === 'error') {
|
||||
showToast(run.error || 'Falha durante a execucao do backup.', true);
|
||||
} else {
|
||||
const backupStatus = run.result?.status;
|
||||
const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
|
||||
if (failures.length) {
|
||||
const details = failures
|
||||
.map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
|
||||
.join(' | ');
|
||||
showToast(`Backup com falhas. ${details}`, true);
|
||||
} else {
|
||||
showToast(
|
||||
backupStatus === 'ok' ? 'Backup concluido.' : 'Backup concluido com falhas parciais.',
|
||||
backupStatus !== 'ok',
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'restore') {
|
||||
if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backups = await api(`/api/profiles/${profileId}/backups`);
|
||||
const selectedBackup = backups.find((item) => item.id === backupId);
|
||||
if (!selectedBackup) {
|
||||
throw new Error('Backup selecionado nao encontrado.');
|
||||
}
|
||||
|
||||
const selectedContainerIds = await askRestoreContainerSelection(profile, selectedBackup);
|
||||
if (!selectedContainerIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Restaurando...';
|
||||
|
||||
const start = await api(`/api/profiles/${profileId}/restore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ backupId, containerIds: selectedContainerIds }),
|
||||
});
|
||||
state.activeRuns.set(profileId, {
|
||||
id: start.runId,
|
||||
kind: 'restore',
|
||||
profileId,
|
||||
status: 'running',
|
||||
progress: {
|
||||
operation: 'restore',
|
||||
overall: {
|
||||
total: selectedContainerIds.length,
|
||||
completed: 0,
|
||||
pending: selectedContainerIds.length,
|
||||
percent: 0,
|
||||
},
|
||||
currentContainer: null,
|
||||
},
|
||||
});
|
||||
renderRunProgress(profileId);
|
||||
|
||||
const run = await pollRun(profileId, start.runId);
|
||||
state.activeRuns.delete(profileId);
|
||||
await loadProfiles();
|
||||
|
||||
if (run.status === 'error') {
|
||||
showToast(run.error || 'Falha durante a execucao do restore.', true);
|
||||
} else {
|
||||
const restoreStatus = run.result?.status;
|
||||
const restoreStatsLines = (run.result?.containers || [])
|
||||
.filter((item) => item.status === 'ok' && item.stats)
|
||||
.map((item) => `${item.containerName}: apagados ${item.stats.deleted}, criados ${item.stats.created}, modificados ${item.stats.modified}`);
|
||||
|
||||
const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
|
||||
if (failures.length) {
|
||||
const details = failures
|
||||
.map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
|
||||
.join(' | ');
|
||||
const statsSummary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
|
||||
showToast(`Restore com falhas. ${details}.${statsSummary}`, true);
|
||||
} else {
|
||||
const summary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
|
||||
showToast(
|
||||
`${restoreStatus === 'ok' ? 'Restore concluido.' : 'Restore concluido com falhas parciais.'}${summary}`,
|
||||
restoreStatus !== 'ok',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await Promise.all([loadContainers(), loadProfiles()]);
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
elements.profileForm.addEventListener('submit', saveProfile);
|
||||
document.querySelector('#refreshContainers').addEventListener('click', init);
|
||||
document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles);
|
||||
document.querySelector('#clearForm').addEventListener('click', resetForm);
|
||||
elements.profilesList.addEventListener('click', handleProfileAction);
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,113 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Docker Backup Profiles</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Docker volume backup</p>
|
||||
<h1>Profiles de backup com parada controlada por container.</h1>
|
||||
<p class="hero-copy">
|
||||
O app usa a Docker API para parar cada container, gerar backup compactado dos mounts e religar apenas quem estava rodando antes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<div>
|
||||
<span class="metric-label">Containers detectados</span>
|
||||
<strong id="containerCount">0</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="metric-label">Profiles salvos</span>
|
||||
<strong id="profileCount">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel form-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Novo profile</h2>
|
||||
<span id="formModeBadge" class="badge">criar</span>
|
||||
</div>
|
||||
|
||||
<form id="profileForm">
|
||||
<input type="hidden" id="profileId" />
|
||||
|
||||
<label>
|
||||
<span>Nome do profile</span>
|
||||
<input id="profileName" name="name" type="text" placeholder="Backup banco principal" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Diretorio de backup no host Docker</span>
|
||||
<input id="backupDir" name="backupDir" type="text" placeholder="/srv/docker-backups" required />
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Escopo do backup</legend>
|
||||
<div class="radio-grid">
|
||||
<label class="radio-card">
|
||||
<input type="radio" name="backupScope" value="volumes" checked />
|
||||
<span>Somente volumes</span>
|
||||
<small>mantem o comportamento atual de backup de volumes e binds</small>
|
||||
</label>
|
||||
<label class="radio-card">
|
||||
<input type="radio" name="backupScope" value="container" />
|
||||
<span>Container inteiro</span>
|
||||
<small>gera um unico tar por container a partir de /</small>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="container-picker">
|
||||
<div class="picker-header">
|
||||
<h3>Containers</h3>
|
||||
<button id="refreshContainers" type="button" class="secondary-button">Atualizar lista</button>
|
||||
</div>
|
||||
<div id="containerOptions" class="container-options"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="primary-button" type="submit">Salvar profile</button>
|
||||
<button class="secondary-button" type="button" id="clearForm">Limpar</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel list-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Profiles salvos</h2>
|
||||
<button id="reloadProfiles" type="button" class="secondary-button">Recarregar</button>
|
||||
</div>
|
||||
<div id="profilesList" class="profiles-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside id="toast" class="toast hidden"></aside>
|
||||
<div id="restoreModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-backdrop" data-action="close-restore-modal"></div>
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="restoreModalTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="restoreModalTitle">Selecionar containers para restore</h3>
|
||||
<button id="restoreModalClose" class="ghost-button small" type="button">Fechar</button>
|
||||
</div>
|
||||
<p id="restoreModalSubtitle" class="modal-subtitle"></p>
|
||||
<div id="restoreContainerOptions" class="modal-options"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="restoreModalSelectAll" class="secondary-button" type="button">Marcar todos</button>
|
||||
<button id="restoreModalConfirm" class="primary-button" type="button">Restaurar selecionados</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,548 @@
|
||||
:root {
|
||||
--bg: #f3efe6;
|
||||
--bg-soft: #fffaf2;
|
||||
--surface: rgba(255, 252, 245, 0.78);
|
||||
--line: rgba(69, 55, 37, 0.14);
|
||||
--text: #2e2418;
|
||||
--muted: #7b6c58;
|
||||
--accent: #156669;
|
||||
--accent-strong: #0b4d50;
|
||||
--danger: #a03838;
|
||||
--shadow: 0 20px 60px rgba(63, 43, 17, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(21, 102, 105, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 85% 20%, rgba(206, 131, 76, 0.18), transparent 22%),
|
||||
linear-gradient(180deg, #fbf7ef 0%, #efe6d5 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 64px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 0.8fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.2rem, 4vw, 4.4rem);
|
||||
line-height: 0.96;
|
||||
max-width: 10ch;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 64ch;
|
||||
color: var(--muted);
|
||||
font-size: 1.02rem;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel,
|
||||
.toast {
|
||||
backdrop-filter: blur(14px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-card strong {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 420px) 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 28px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.picker-header,
|
||||
.profile-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h2,
|
||||
.picker-header h3,
|
||||
.profile-card h3,
|
||||
.restore-block h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.chip,
|
||||
.state,
|
||||
.profile-card code {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.chip {
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(21, 102, 105, 0.1);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
form,
|
||||
.restore-block,
|
||||
.profiles-list {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
label,
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label span,
|
||||
legend {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.radio-card,
|
||||
.container-option,
|
||||
.backup-item {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.radio-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.radio-card input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.radio-card small,
|
||||
.container-option small,
|
||||
.profile-card p,
|
||||
.empty-state,
|
||||
.empty-inline {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.container-options,
|
||||
.chips,
|
||||
.backup-history {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.container-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.state {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.state.running {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.state.exited,
|
||||
.state.created {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button,
|
||||
.backup-item {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, opacity 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: rgba(21, 102, 105, 0.12);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(160, 56, 56, 0.08);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.primary-button:hover,
|
||||
.secondary-button:hover,
|
||||
.ghost-button:hover,
|
||||
.backup-item:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled,
|
||||
.ghost-button:disabled,
|
||||
.backup-item:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.form-actions,
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.run-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.mode-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.mode-picker span {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mode-select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-card p,
|
||||
.profile-card code {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.profile-card code {
|
||||
display: inline-block;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-item em {
|
||||
align-self: center;
|
||||
color: var(--accent-strong);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.backup-error {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.run-progress {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.progress-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(240, 252, 252, 0.72);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-header,
|
||||
.progress-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-header small,
|
||||
.progress-label-row,
|
||||
.current-file {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.progress-block {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(21, 102, 105, 0.16);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #17a2a8 0%, #156669 100%);
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
|
||||
.current-file {
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-log {
|
||||
max-height: 140px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.progress-log small {
|
||||
color: var(--muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
max-width: 360px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: rgba(160, 56, 56, 0.24);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 16, 10, 0.45);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
position: relative;
|
||||
width: min(640px, calc(100vw - 24px));
|
||||
max-height: min(80vh, 620px);
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-soft);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-header h3,
|
||||
.modal-subtitle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
}
|
||||
|
||||
.modal-option small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero,
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-shell {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.hero-card {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container-option,
|
||||
.profile-card-top,
|
||||
.backup-item {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.run-actions,
|
||||
.mode-picker {
|
||||
justify-items: stretch;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user