// ─── i18n ───────────────────────────────────────────────── const TRANSLATIONS = window.TRANSLATIONS || {}; const LOCALE_NAMES = window.LOCALE_NAMES || {}; let currentLang = localStorage.getItem('lang') || 'pt-BR'; function t(key) { return (TRANSLATIONS[currentLang] || TRANSLATIONS['pt-BR'] || {})[key] || key; } function applyTranslations() { document.documentElement.lang = currentLang; document.querySelectorAll('[data-i18n]').forEach((el) => { const key = el.dataset.i18n; el.textContent = t(key); }); } // ─── Themes ─────────────────────────────────────────────── const VALID_THEMES = new Set([ 'default','dark','sunrise','forest','ocean','purple','rose','orange','graphite','sapphire','contrast' ]); function applyTheme(theme) { const safe = VALID_THEMES.has(theme) ? theme : 'default'; if (safe === 'default') { document.documentElement.removeAttribute('data-theme'); } else { document.documentElement.setAttribute('data-theme', safe); } localStorage.setItem('theme', safe); const sel = document.querySelector('#settingsTheme'); if (sel) sel.value = safe; } // Apply saved theme immediately applyTheme(localStorage.getItem('theme') || 'default'); document.addEventListener('change', (e) => { if (e.target.id === 'settingsTheme') applyTheme(e.target.value); }); // ─── Auth ────────────────────────────────────────────────── let authToken = localStorage.getItem('authToken') || null; function getAuthHeaders() { const headers = { 'Content-Type': 'application/json' }; if (authToken) { headers['x-auth-token'] = authToken; } return headers; } const state = { containers: [], profiles: [], storageLocations: [], schedules: [], sources: [], activeRuns: new Map(), volumeSelections: {}, }; const elements = { containerCount: document.querySelector('#containerCount'), profileCount: document.querySelector('#profileCount'), profileForm: document.querySelector('#profileForm'), profileId: document.querySelector('#profileId'), profileName: document.querySelector('#profileName'), storageLocationSelect: document.querySelector('#storageLocationId'), containerOptions: document.querySelector('#containerOptions'), profilesList: document.querySelector('#profilesList'), toast: document.querySelector('#toast'), profileFormModal: document.querySelector('#profileFormModal'), profileModalClose: document.querySelector('#profileModalClose'), restoreModal: document.querySelector('#restoreModal'), restoreModalSubtitle: document.querySelector('#restoreModalSubtitle'), restoreContainerOptions: document.querySelector('#restoreContainerOptions'), restoreModalConfirm: document.querySelector('#restoreModalConfirm'), restoreModalClose: document.querySelector('#restoreModalClose'), restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'), volumePickerModal: document.querySelector('#volumePickerModal'), volumePickerSubtitle: document.querySelector('#volumePickerSubtitle'), volumePickerOptions: document.querySelector('#volumePickerOptions'), volumePickerConfirm: document.querySelector('#volumePickerConfirm'), volumePickerClose: document.querySelector('#volumePickerClose'), volumePickerSelectAll: document.querySelector('#volumePickerSelectAll'), fullBackupPickerModal: document.querySelector('#fullBackupPickerModal'), fullBackupPickerOptions: document.querySelector('#fullBackupPickerOptions'), fullBackupPickerConfirm: document.querySelector('#fullBackupPickerConfirm'), fullBackupPickerClose: document.querySelector('#fullBackupPickerClose'), storageLocationFormModal: document.querySelector('#storageLocationFormModal'), storageLocationForm: document.querySelector('#storageLocationForm'), storageLocationName: document.querySelector('#storageLocationName'), storageLocationDir: document.querySelector('#storageLocationDir'), storageLocationIdField: document.querySelector('#storageFormId'), storageLocationsList: document.querySelector('#storageLocationsList'), // storage type fields storageHost: document.querySelector('#storageHost'), storagePort: document.querySelector('#storagePort'), storageUsername: document.querySelector('#storageUsername'), storagePassword: document.querySelector('#storagePassword'), storageRemotePath: document.querySelector('#storageRemotePath'), storagePassive: document.querySelector('#storagePassive'), storagePrivateKey: document.querySelector('#storagePrivateKey'), storageWebdavUrl: document.querySelector('#storageWebdavUrl'), storageWebdavUsername: document.querySelector('#storageWebdavUsername'), storageWebdavPassword: document.querySelector('#storageWebdavPassword'), storageWebdavRemotePath: document.querySelector('#storageWebdavRemotePath'), storageGdriveClientId: document.querySelector('#storageGdriveClientId'), storageGdriveClientSecret: document.querySelector('#storageGdriveClientSecret'), storageGdriveRefreshToken: document.querySelector('#storageGdriveRefreshToken'), storageGdriveFolderId: document.querySelector('#storageGdriveFolderId'), sourceFormModal: document.querySelector('#sourceFormModal'), sourceForm: document.querySelector('#sourceForm'), sourceFormId: document.querySelector('#sourceFormId'), sourceFormName: document.querySelector('#sourceFormName'), sourceFormHost: document.querySelector('#sourceFormHost'), sourceFormPort: document.querySelector('#sourceFormPort'), sourcesList: document.querySelector('#sourcesList'), profileSourceSelect: document.querySelector('#profileSourceId'), }; // ─── View navigation ────────────────────────────────────── function navigateTo(viewName) { for (const view of document.querySelectorAll('.view')) { view.classList.add('hidden'); } const target = document.querySelector(`#view-${viewName}`); if (target) { target.classList.remove('hidden'); } for (const item of document.querySelectorAll('.nav-item')) { item.classList.toggle('active', item.dataset.view === viewName); } if (viewName === 'profiles') { loadProfiles(); loadContainers(); } if (viewName === 'runs') { loadAllRuns(); } if (viewName === 'backups') { renderBackupsView(); } if (viewName === 'schedules') { loadSchedules(); } if (viewName === 'storage') { loadStorageLocations(); } if (viewName === 'source') { loadSources(); } if (viewName === 'settings') { loadSettingsView(); } if (viewName === 'about') { loadAboutView(); } } document.querySelector('.sidebar').addEventListener('click', (e) => { const btn = e.target.closest('.nav-item[data-view]'); if (btn) { navigateTo(btn.dataset.view); } }); document.querySelector('#createProfileBtn')?.addEventListener('click', () => navigateTo('profiles')); // ─── Profile form modal ─────────────────────────────────── function openProfileModal(title = 'Novo Profile') { document.querySelector('#profileModalTitle').textContent = title; elements.profileFormModal.classList.remove('hidden'); elements.profileFormModal.setAttribute('aria-hidden', 'false'); } function closeProfileModal() { elements.profileFormModal.classList.add('hidden'); elements.profileFormModal.setAttribute('aria-hidden', 'true'); } document.querySelector('#openCreateProfileModal')?.addEventListener('click', () => { resetForm(); populateStorageLocationDropdown(); populateSourceDropdown(); openProfileModal('Novo Profile'); }); elements.profileModalClose?.addEventListener('click', closeProfileModal); elements.profileFormModal?.addEventListener('click', (e) => { if (e.target.closest('[data-action="close-profile-modal"]')) { closeProfileModal(); } }); // ─── Storage Locations ──────────────────────────────────── async function loadStorageLocations() { state.storageLocations = await api('/api/storage-locations'); renderStorageLocationsList(); populateStorageLocationDropdown(); } function renderStorageLocationsList() { const list = elements.storageLocationsList; if (!list) return; if (!state.storageLocations.length) { list.innerHTML = '

Nenhum local de armazenamento configurado. Crie um para poder configurar backup profiles.

'; return; } const STORAGE_TYPE_LABELS = { 'local': 'Local', 'ftp': 'FTP', 'sftp': 'SFTP', 'webdav': 'WebDAV', 'google-drive': 'Google Drive', }; function storageLocationSummary(loc) { const type = loc.type || 'local'; if (type === 'local') return `${escapeHtml(loc.directory || '')}`; if (type === 'ftp' || type === 'sftp') { const host = loc.host ? escapeHtml(loc.host) : '—'; const port = loc.port ? `:${loc.port}` : ''; const path = loc.remotePath ? escapeHtml(loc.remotePath) : ''; return `${host}${port}${path ? '/' + path.replace(/^\//, '') : ''}`; } if (type === 'webdav') return `${escapeHtml(loc.url || '')}`; if (type === 'google-drive') { return loc.folderId ? `folder: ${escapeHtml(loc.folderId)}` : 'Drive raiz'; } return ''; } list.innerHTML = ` ${state.storageLocations.map((loc) => ` `).join('')}
NomeTipoDestinoAções
${escapeHtml(loc.name)} ${escapeHtml(STORAGE_TYPE_LABELS[loc.type || 'local'] || loc.type || 'local')} ${storageLocationSummary(loc)}
`; } function populateStorageLocationDropdown() { const select = elements.storageLocationSelect; if (!select) return; const current = select.value; const STORAGE_TYPE_LABELS = { 'local': 'Local', 'ftp': 'FTP', 'sftp': 'SFTP', 'webdav': 'WebDAV', 'google-drive': 'Google Drive' }; select.innerHTML = '' + state.storageLocations.map((loc) => { const typeLabel = STORAGE_TYPE_LABELS[loc.type || 'local'] || loc.type || 'local'; const detail = (loc.type === 'local' || !loc.type) ? (loc.directory || '') : (loc.host || loc.url || loc.clientId || ''); return ``; }).join(''); if (current) select.value = current; } function updateStorageTypeFields(type) { const sections = { local: document.querySelector('#storageFieldsLocal'), ftpSftp: document.querySelector('#storageFieldsFtpSftp'), ftpOnly: document.querySelector('#storageFieldsFtpOnly'), sftpOnly: document.querySelector('#storageFieldsSftpOnly'), webdav: document.querySelector('#storageFieldsWebdav'), gdrive: document.querySelector('#storageFieldsGdrive'), }; const show = (el) => el?.classList.remove('hidden'); const hide = (el) => el?.classList.add('hidden'); hide(sections.local); hide(sections.ftpSftp); hide(sections.ftpOnly); hide(sections.sftpOnly); hide(sections.webdav); hide(sections.gdrive); if (type === 'local') { show(sections.local); // Update port placeholder when switching to local doesn't apply } else if (type === 'ftp') { show(sections.ftpSftp); show(sections.ftpOnly); if (elements.storagePort && !elements.storagePort.value) elements.storagePort.placeholder = '21'; } else if (type === 'sftp') { show(sections.ftpSftp); show(sections.sftpOnly); if (elements.storagePort && !elements.storagePort.value) elements.storagePort.placeholder = '22'; } else if (type === 'webdav') { show(sections.webdav); } else if (type === 'google-drive') { show(sections.gdrive); } } function openStorageModal() { document.querySelector('#storageModalTitle').textContent = 'Novo Local de Armazenamento'; elements.storageLocationForm.reset(); elements.storageLocationIdField.value = ''; // Reset to local type const localRadio = elements.storageLocationForm.querySelector('input[name="storageType"][value="local"]'); if (localRadio) localRadio.checked = true; updateStorageTypeFields('local'); elements.storageLocationFormModal.classList.remove('hidden'); elements.storageLocationFormModal.setAttribute('aria-hidden', 'false'); } function closeStorageModal() { elements.storageLocationFormModal.classList.add('hidden'); elements.storageLocationFormModal.setAttribute('aria-hidden', 'true'); } async function saveStorageLocation(event) { event.preventDefault(); const form = elements.storageLocationForm; const selectedTypeInput = form.querySelector('input[name="storageType"]:checked'); const type = selectedTypeInput ? selectedTypeInput.value : 'local'; const payload = { id: elements.storageLocationIdField.value || undefined, name: elements.storageLocationName.value.trim(), type, }; if (type === 'local') { payload.directory = elements.storageLocationDir.value.trim(); if (!payload.directory) { showToast('Informe o diretório para armazenamento local.', true); return; } } else if (type === 'ftp' || type === 'sftp') { payload.host = elements.storageHost.value.trim(); payload.port = elements.storagePort.value ? Number(elements.storagePort.value) : (type === 'ftp' ? 21 : 22); payload.username = elements.storageUsername.value.trim(); payload.password = elements.storagePassword.value; payload.remotePath = elements.storageRemotePath.value.trim(); if (!payload.host || !payload.username) { showToast('Informe host e usuário para ' + type.toUpperCase() + '.', true); return; } if (type === 'ftp') { payload.passive = elements.storagePassive.checked; } else { payload.privateKey = elements.storagePrivateKey.value; } } else if (type === 'webdav') { payload.url = elements.storageWebdavUrl.value.trim(); payload.username = elements.storageWebdavUsername.value.trim(); payload.password = elements.storageWebdavPassword.value; payload.remotePath = elements.storageWebdavRemotePath.value.trim(); if (!payload.url) { showToast('Informe a URL do servidor WebDAV.', true); return; } } else if (type === 'google-drive') { payload.clientId = elements.storageGdriveClientId.value.trim(); payload.clientSecret = elements.storageGdriveClientSecret.value; payload.refreshToken = elements.storageGdriveRefreshToken.value; payload.folderId = elements.storageGdriveFolderId.value.trim(); if (!payload.clientId || !payload.clientSecret || !payload.refreshToken) { showToast('Informe Client ID, Client Secret e Refresh Token para Google Drive.', true); return; } } try { await api('/api/storage-locations', { method: 'POST', body: JSON.stringify(payload), }); closeStorageModal(); await loadStorageLocations(); showToast('Local de armazenamento salvo.'); } catch (error) { showToast(error.message, true); } } document.querySelector('#openCreateStorageModal')?.addEventListener('click', openStorageModal); document.querySelector('#cancelStorageForm')?.addEventListener('click', closeStorageModal); document.querySelector('#storageModalClose')?.addEventListener('click', closeStorageModal); elements.storageLocationFormModal?.addEventListener('click', (e) => { if (e.target.closest('[data-action="close-storage-modal"]')) closeStorageModal(); }); elements.storageLocationFormModal?.addEventListener('change', (e) => { if (e.target.name === 'storageType') updateStorageTypeFields(e.target.value); }); elements.storageLocationForm?.addEventListener('submit', saveStorageLocation); elements.storageLocationsList?.addEventListener('click', async (e) => { const btn = e.target.closest('[data-storage-action="delete"]'); if (!btn) return; const id = btn.dataset.storageId; // Fetch impact before confirming let impact = { profileCount: 0, profileNames: [], backupCount: 0 }; try { impact = await api(`/api/storage-locations/${id}/impact`); } catch { // Non-fatal; proceed with generic message } let message = 'Excluir este local de armazenamento?'; if (impact.profileCount > 0) { const names = impact.profileNames.map((n) => `• ${n}`).join('\n'); message = `⚠️ ATENÇÃO: Esta ação também irá excluir permanentemente:\n\n` + ` ${impact.profileCount} profile(s) de backup:\n${names}\n\n` + ` ${impact.backupCount} backup(s) registrado(s) desses profiles\n\n` + `Deseja continuar?`; } if (!window.confirm(message)) return; try { await api(`/api/storage-locations/${id}`, { method: 'DELETE' }); await Promise.all([loadStorageLocations(), loadProfiles()]); showToast(impact.profileCount > 0 ? `Local removido junto com ${impact.profileCount} profile(s) e ${impact.backupCount} backup(s).` : 'Local de armazenamento removido.'); } catch (error) { showToast(error.message, true); } }); // ─── Sources ────────────────────────────────────────────── const SOURCE_TYPE_LABELS = { 'unix-socket': 'Unix Socket', 'direct': 'Direto (TCP)', 'agent': 'Docker Agent', }; let _unixSocketAvailable = false; async function checkUnixSocket() { try { const result = await api('/api/sources/check-unix-socket'); _unixSocketAvailable = result.available === true; } catch { _unixSocketAvailable = false; } const card = document.querySelector('#unixSocketRadioCard'); const msg = document.querySelector('#unixSocketUnavailableMsg'); const radio = document.querySelector('#sourceTypeUnixSocket'); if (card) card.style.opacity = _unixSocketAvailable ? '' : '0.45'; if (radio) radio.disabled = !_unixSocketAvailable; if (msg) msg.classList.toggle('hidden', _unixSocketAvailable); } async function loadSources() { try { state.sources = await api('/api/sources'); renderSourcesList(); populateSourceDropdown(); } catch (error) { showToast(error.message, true); } } function renderSourcesList() { const list = elements.sourcesList; if (!list) return; if (!state.sources.length) { list.innerHTML = '

Nenhuma origem configurada. Crie uma para poder conectar a diferentes hosts Docker.

'; applyTranslations(); return; } list.innerHTML = ` ${state.sources.map((src) => { const connInfo = src.type === 'unix-socket' ? (src.socketPath || '/var/run/docker.sock') : `${src.host || '—'}:${src.port || 2375}`; return ` `; }).join('')}
NomeTipoConexãoAções
${escapeHtml(src.name)} ${escapeHtml(SOURCE_TYPE_LABELS[src.type] || src.type)} ${escapeHtml(connInfo)}
`; applyTranslations(); } function populateSourceDropdown() { const select = elements.profileSourceSelect; if (!select) return; const current = select.value; select.innerHTML = `` + state.sources.map((src) => `` ).join(''); if (current) select.value = current; } function openSourceModal() { document.querySelector('#sourceModalTitle').textContent = 'Nova Origem'; elements.sourceForm?.reset(); if (elements.sourceFormId) elements.sourceFormId.value = ''; // Default to 'direct' type const directRadio = document.querySelector('#sourceTypeDirect'); if (directRadio) directRadio.checked = true; updateSourceTypeFields('direct'); elements.sourceFormModal?.classList.remove('hidden'); elements.sourceFormModal?.setAttribute('aria-hidden', 'false'); checkUnixSocket(); } function closeSourceModal() { elements.sourceFormModal?.classList.add('hidden'); elements.sourceFormModal?.setAttribute('aria-hidden', 'true'); } function updateSourceTypeFields(type) { const hostFields = document.querySelector('#sourceHostFields'); if (!hostFields) return; if (type === 'unix-socket') { hostFields.classList.add('hidden'); } else { hostFields.classList.remove('hidden'); const portInput = elements.sourceFormPort; if (portInput && !portInput.value) { portInput.value = type === 'agent' ? '9000' : '2375'; } } } async function saveSource(event) { event.preventDefault(); const type = document.querySelector('input[name="sourceType"]:checked')?.value; if (!type) { showToast('Selecione o tipo de origem.', true); return; } const payload = { id: elements.sourceFormId?.value || undefined, name: elements.sourceFormName?.value.trim(), type, host: type !== 'unix-socket' ? elements.sourceFormHost?.value.trim() : undefined, port: type !== 'unix-socket' ? (Number(elements.sourceFormPort?.value) || null) : undefined, socketPath: type === 'unix-socket' ? '/var/run/docker.sock' : undefined, }; try { await api('/api/sources', { method: 'POST', body: JSON.stringify(payload) }); closeSourceModal(); await loadSources(); showToast(t('source.saved')); } catch (error) { showToast(error.message, true); } } document.querySelector('#openCreateSourceModal')?.addEventListener('click', openSourceModal); document.querySelector('#cancelSourceForm')?.addEventListener('click', closeSourceModal); document.querySelector('#sourceModalClose')?.addEventListener('click', closeSourceModal); elements.sourceFormModal?.addEventListener('click', (e) => { if (e.target.closest('[data-action="close-source-modal"]')) closeSourceModal(); }); elements.sourceForm?.addEventListener('submit', saveSource); document.querySelectorAll('input[name="sourceType"]').forEach((radio) => { radio.addEventListener('change', (e) => updateSourceTypeFields(e.target.value)); }); elements.sourcesList?.addEventListener('click', async (e) => { const btn = e.target.closest('[data-source-action="delete"]'); if (!btn) return; const id = btn.dataset.sourceId; let impact = { profileCount: 0, profileNames: [], backupCount: 0 }; try { impact = await api(`/api/sources/${id}/impact`); } catch { // Non-fatal } let message = t('source.confirmDelete'); if (impact.profileCount > 0) { const names = impact.profileNames.map((n) => `• ${n}`).join('\n'); message = `⚠️ ATENÇÃO: Esta ação também irá excluir permanentemente:\n\n` + ` ${impact.profileCount} profile(s) de backup:\n${names}\n\n` + ` ${impact.backupCount} backup(s) registrado(s) desses profiles\n\n` + `Deseja continuar?`; } if (!window.confirm(message)) return; try { await api(`/api/sources/${id}`, { method: 'DELETE' }); await Promise.all([loadSources(), loadProfiles()]); showToast(impact.profileCount > 0 ? `Origem removida junto com ${impact.profileCount} profile(s) e ${impact.backupCount} backup(s).` : t('source.deleted')); } catch (error) { showToast(error.message, true); } }); // When source changes in profile form, reload containers for that source elements.profileSourceSelect?.addEventListener('change', async () => { const sourceId = elements.profileSourceSelect.value || null; const url = sourceId ? `/api/containers?sourceId=${encodeURIComponent(sourceId)}` : '/api/containers'; try { state.containers = await api(url); renderContainers(); } catch { // Non-fatal } }); // ─── Directory Browser Modal ──────────────────────────────let _dirBrowserCurrentPath = '/'; function openDirBrowser() { const initial = elements.storageLocationDir.value.trim() || '/'; _dirBrowserCurrentPath = initial; const modal = document.querySelector('#dirBrowserModal'); modal.classList.remove('hidden'); modal.setAttribute('aria-hidden', 'false'); loadDirBrowserPath(initial); } function closeDirBrowser() { const modal = document.querySelector('#dirBrowserModal'); modal.classList.add('hidden'); modal.setAttribute('aria-hidden', 'true'); } async function loadDirBrowserPath(dirPath) { _dirBrowserCurrentPath = dirPath; document.querySelector('#dirBrowserCurrent').textContent = dirPath; const list = document.querySelector('#dirBrowserList'); list.innerHTML = '
Carregando…
'; let data; try { data = await api(`/api/browse-dirs?path=${encodeURIComponent(dirPath)}`); } catch (err) { list.innerHTML = `
${escapeHtml(err.message)}
`; return; } const folderSvg = ``; const upSvg = ``; let html = ''; if (data.parent !== null) { html += `
${upSvg}.. (subir)
`; } if (data.dirs.length === 0 && data.parent === null) { html += '
Nenhum subdiretório encontrado.
'; } else { html += data.dirs.map((d) => `
${folderSvg}${escapeHtml(d.name)}
` ).join(''); } list.innerHTML = html; list.querySelectorAll('.dir-browser-item[data-dir-path]').forEach((item) => { item.addEventListener('click', () => loadDirBrowserPath(item.dataset.dirPath)); }); } document.querySelector('#browseDirBtn')?.addEventListener('click', openDirBrowser); document.querySelector('#dirBrowserClose')?.addEventListener('click', closeDirBrowser); document.querySelector('#dirBrowserCancel')?.addEventListener('click', closeDirBrowser); document.querySelector('#dirBrowserBackdrop')?.addEventListener('click', closeDirBrowser); document.querySelector('#dirBrowserSelect')?.addEventListener('click', () => { elements.storageLocationDir.value = _dirBrowserCurrentPath; closeDirBrowser(); }); // ─── Full Backup Picker Modal ───────────────────────────── function askFullBackupSelection(fullBackups, profileName) { elements.fullBackupPickerOptions.innerHTML = fullBackups.map((b) => ` `).join(''); // Pre-select the most recent one const firstRadio = elements.fullBackupPickerOptions.querySelector('input[name="fullBackupChoice"]'); if (firstRadio) firstRadio.checked = true; elements.fullBackupPickerModal.classList.remove('hidden'); elements.fullBackupPickerModal.setAttribute('aria-hidden', 'false'); return new Promise((resolve) => { const closeModal = () => { elements.fullBackupPickerModal.classList.add('hidden'); elements.fullBackupPickerModal.setAttribute('aria-hidden', 'true'); elements.fullBackupPickerOptions.innerHTML = ''; }; const cleanup = () => { elements.fullBackupPickerConfirm.removeEventListener('click', onConfirm); elements.fullBackupPickerClose.removeEventListener('click', onCancel); elements.fullBackupPickerModal.removeEventListener('click', onBackdropClick); }; const onConfirm = () => { const selected = elements.fullBackupPickerOptions.querySelector('input[name="fullBackupChoice"]:checked'); if (!selected) { showToast('Selecione um backup full.', true); return; } cleanup(); closeModal(); resolve(selected.value); }; const onCancel = () => { cleanup(); closeModal(); resolve(null); }; const onBackdropClick = (event) => { if (event.target.closest('[data-action="close-full-backup-picker"]')) onCancel(); }; elements.fullBackupPickerConfirm.addEventListener('click', onConfirm); elements.fullBackupPickerClose.addEventListener('click', onCancel); elements.fullBackupPickerModal.addEventListener('click', onBackdropClick); }); } async function resolveFullBackupId(profileId, profile) { 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)); if (!fullBackups.length) { showToast('Não há backup full disponível. Execute um backup full primeiro.', true); return undefined; // signal: blocked } if (fullBackups.length === 1) { return fullBackups[0].id; } return askFullBackupSelection(fullBackups, profile.name); } function renderServers() { // Servers view was removed } async function renderBackupsView() { const host = document.querySelector('#backupsViewList'); if (!host) return; if (!state.profiles.length) { host.innerHTML = '

Nenhum profile encontrado.

'; return; } const rows = await Promise.all(state.profiles.map(async (p) => { const backups = await api(`/api/profiles/${p.id}/backups`); return { profile: p, backups }; })); host.innerHTML = rows.map(({ profile, backups }) => { const groups = groupBackupsByFull(backups); const totalBackups = backups.length; const groupsHtml = groups.length ? groups.map(({ full, incrementals }) => { const allInGroup = [full, ...incrementals]; return ` ${renderBackupRow(full, profile, true)} ${incrementals.map((inc) => renderBackupRow(inc, profile, false)).join('')} `; }).join('') : `Nenhum backup realizado.`; return `

${escapeHtml(profile.name)}

${escapeHtml(String(totalBackups))} backup(s)
${groupsHtml}
DataTipoStatusContainersAções
`; }).join(''); renderAllRunProgress(); } function renderBackupRow(b, profile, isFull) { const hasRestorable = (b.containers || []).some((c) => c.status === 'ok'); const indent = isFull ? '' : '   ↳ '; const modeLabel = isFull ? 'Full' : 'Incremental'; return ` ${indent}${escapeHtml(new Date(b.createdAt).toLocaleString('pt-BR'))} ${escapeHtml(modeLabel)} ${escapeHtml(b.status)} ${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))} `; } function groupBackupsByFull(backups) { const chronological = [...backups].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); const groupMap = new Map(); let currentFullId = null; for (const backup of chronological) { if (backup.mode === 'full') { currentFullId = backup.id; groupMap.set(currentFullId, { full: backup, incrementals: [] }); } else if (backup.mode === 'incremental') { const targetId = backup.basedOnFullBackupId || currentFullId; if (targetId && groupMap.has(targetId)) { groupMap.get(targetId).incrementals.push(backup); } } } return [...groupMap.values()].sort((a, b) => new Date(b.full.createdAt) - new Date(a.full.createdAt)); } async function updateDashboard() { const allBackups = (await Promise.all( state.profiles.map((p) => api(`/api/profiles/${p.id}/backups`)) )).flat(); // Last successful const successful = allBackups .filter((b) => b.status === 'ok' || b.status === 'partial') .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); const lastBackupEl = document.querySelector('#lastBackupTime'); if (lastBackupEl) { lastBackupEl.textContent = String(successful.length); } // Failed total const failed = allBackups.filter((b) => b.status === 'error'); const failedEl = document.querySelector('#failedCount'); if (failedEl) failedEl.textContent = String(failed.length); // Recent runs table const recent = [...allBackups] .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) .slice(0, 20); const tbody = document.querySelector('#recentRunsBody'); if (!tbody) return; if (!recent.length) { tbody.innerHTML = 'Nenhum run encontrado.'; return; } tbody.innerHTML = recent.map((b, index) => { const profile = state.profiles.find((p) => p.id === b.profileId); const profileName = profile ? profile.name : (b.profileName || b.profileId || '—'); const started = new Date(b.createdAt).toLocaleString('pt-BR'); const duration = '—'; const fileCount = (b.containers || []).reduce((sum, c) => sum + (c.fileCount || 0), 0); const size = (b.containers || []).reduce((sum, c) => sum + (c.archiveSize || 0), 0); const sizeStr = size > 0 ? formatBytes(size) : '—'; return ` #${index + 1} ${escapeHtml(profileName)} ${escapeHtml(b.status === 'ok' ? 'Completed' : b.status)} ${fileCount || '—'} ${sizeStr} ${started} ${duration} `; }).join(''); document.querySelector('#recentRunsBody')?.addEventListener('click', (e) => { const link = e.target.closest('.profile-link'); if (link) { e.preventDefault(); navigateTo('profiles'); } }, { once: true }); } function formatBytes(bytes) { if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB'; if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + ' MB'; if (bytes >= 1e3) return (bytes / 1e3).toFixed(1) + ' KB'; return bytes + ' B'; } async function loadAllRuns() { const tbody = document.querySelector('#allRunsBody'); if (!tbody) return; tbody.innerHTML = 'Carregando...'; if (!state.profiles.length) { await loadProfiles(); } if (!state.profiles.length) { tbody.innerHTML = 'Nenhum profile encontrado.'; return; } const allBackups = (await Promise.all( state.profiles.map((p) => api(`/api/profiles/${p.id}/backups`)), )).flat(); const sorted = [...allBackups].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); if (!sorted.length) { tbody.innerHTML = 'Nenhum run encontrado.'; return; } tbody.innerHTML = sorted.map((b, index) => { const profile = state.profiles.find((p) => p.id === b.profileId); const profileName = profile ? profile.name : (b.profileName || b.profileId || '—'); const containers = (b.containers || []).map((c) => escapeHtml(c.containerName || c.containerId?.slice(0, 12) || '?')).join(', '); const fileCount = (b.containers || []).reduce((sum, c) => sum + (c.fileCount || 0), 0); const size = (b.containers || []).reduce((sum, c) => sum + (c.archiveSize || 0), 0); const sizeStr = size > 0 ? formatBytes(size) : '—'; const statusLabel = b.status === 'ok' ? 'Completed' : b.status === 'partial' ? 'Partial' : 'Error'; return ` ${index + 1} ${escapeHtml(profileName)} ${escapeHtml(b.mode || '—')} ${escapeHtml(statusLabel)} ${containers || '—'} ${fileCount || '—'} ${sizeStr} ${escapeHtml(new Date(b.createdAt).toLocaleString('pt-BR'))} `; }).join(''); } async function api(path, options = {}) { const response = await fetch(path, { headers: { ...getAuthHeaders(), ...(options.headers || {}), }, ...options, }); if (response.status === 401) { authToken = null; localStorage.removeItem('authToken'); showLoginOverlay(); throw new Error('Sessao expirada. Faca login novamente.'); } 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); } const SYSTEM_MOUNT_PREFIXES = ['/dev', '/proc', '/sys', '/run', '/tmp']; function isMountBlocked(destination) { return SYSTEM_MOUNT_PREFIXES.some( (prefix) => destination === prefix || destination.startsWith(prefix + '/'), ); } function renderContainerOption(container, selected) { const hasVolumeSelection = Boolean(state.volumeSelections[container.id]?.length); return ` `; } function renderContainers() { elements.containerCount.textContent = String(state.containers.length); const eligible = state.containers.filter((container) => container.state !== 'created'); if (!eligible.length) { elements.containerOptions.innerHTML = '

Nenhum container encontrado.

'; return; } const selected = new Set(getSelectedContainerIds()); // Separate compose containers from standalone const composeGroups = new Map(); // project -> container[] const standalone = []; for (const container of eligible) { if (container.composeProject) { if (!composeGroups.has(container.composeProject)) composeGroups.set(container.composeProject, []); composeGroups.get(container.composeProject).push(container); } else { standalone.push(container); } } const parts = []; // Compose groups first, sorted by project name for (const [project, containers] of [...composeGroups.entries()].sort(([a], [b]) => a.localeCompare(b))) { parts.push(`
${escapeHtml(project)}
${containers.map((c) => renderContainerOption(c, selected)).join('')}
`); } // Standalone containers if (standalone.length) { const standaloneHeader = composeGroups.size > 0 ? `
Containers avulsos
${standalone.map((c) => renderContainerOption(c, selected)).join('')}
` : standalone.map((c) => renderContainerOption(c, selected)).join(''); parts.push(standaloneHeader); } elements.containerOptions.innerHTML = parts.join(''); } function backupButtons(profile) { const isRunning = state.activeRuns.has(profile.id); return `
`; } 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'; } async function askVolumeSelection(containerId, containerName, currentSelections, sourceId) { let mounts; try { const mountsUrl = sourceId ? `/api/containers/${encodeURIComponent(containerId)}/mounts?sourceId=${encodeURIComponent(sourceId)}` : `/api/containers/${encodeURIComponent(containerId)}/mounts`; mounts = await api(mountsUrl); } catch (error) { showToast(`Falha ao buscar volumes de ${containerName}: ${error.message}`, true); return null; } if (!mounts.length) { showToast(`Container ${containerName} não possui volumes.`, true); return null; } const hasEligible = mounts.some((m) => !isMountBlocked(m.destination)); if (!hasEligible) { showToast(`Container ${containerName} não possui volumes elegíveis (todos são caminhos de sistema).`, true); return null; } elements.volumePickerSubtitle.textContent = `Container: ${containerName}`; elements.volumePickerOptions.innerHTML = mounts.map((mount) => { const blocked = isMountBlocked(mount.destination); const isChecked = currentSelections ? currentSelections.includes(mount.destination) : !blocked; return ` `; }).join(''); elements.volumePickerModal.classList.remove('hidden'); elements.volumePickerModal.setAttribute('aria-hidden', 'false'); return new Promise((resolve) => { const closeModal = () => { elements.volumePickerModal.classList.add('hidden'); elements.volumePickerModal.setAttribute('aria-hidden', 'true'); elements.volumePickerOptions.innerHTML = ''; }; const cleanup = () => { elements.volumePickerConfirm.removeEventListener('click', onConfirm); elements.volumePickerClose.removeEventListener('click', onCancel); elements.volumePickerSelectAll.removeEventListener('click', onSelectAll); elements.volumePickerModal.removeEventListener('click', onBackdropClick); }; const onConfirm = () => { const selected = [...elements.volumePickerOptions.querySelectorAll('input[name="volumePaths"]:checked')] .map((input) => input.value); if (!selected.length) { showToast('Selecione ao menos um volume.', true); return; } cleanup(); closeModal(); resolve(selected); }; const onCancel = () => { cleanup(); closeModal(); resolve(null); }; const onSelectAll = () => { for (const input of elements.volumePickerOptions.querySelectorAll('input[name="volumePaths"]:not([disabled])')) { input.checked = true; } }; const onBackdropClick = (event) => { if (event.target.closest('[data-action="close-volume-picker"]')) { onCancel(); } }; elements.volumePickerConfirm.addEventListener('click', onConfirm); elements.volumePickerClose.addEventListener('click', onCancel); elements.volumePickerSelectAll.addEventListener('click', onSelectAll); elements.volumePickerModal.addEventListener('click', onBackdropClick); }); } async function handleContainerCheck(event) { const input = event.target.closest('input[name="containerIds"]'); if (!input) { return; } const scope = document.querySelector('input[name="backupScope"]:checked')?.value; if (scope !== 'volumes') { return; } const containerId = input.value; const label = input.closest('label'); const containerName = label?.querySelector('strong')?.textContent || containerId.slice(0, 12); if (!input.checked) { delete state.volumeSelections[containerId]; renderContainers(); input.checked = false; return; } const currentSelections = state.volumeSelections[containerId] || null; const sourceId = elements.profileSourceSelect?.value || null; const selected = await askVolumeSelection(containerId, containerName, currentSelections, sourceId); if (selected === null) { input.checked = false; return; } state.volumeSelections[containerId] = selected; renderContainers(); const updatedInput = document.querySelector(`input[name="containerIds"][value="${CSS.escape(containerId)}"]`); if (updatedInput) { updatedInput.checked = true; } } 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) => ` `).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 ` Falhas: ${failures.map((item) => `${escapeHtml(item.containerName || item.containerId || 'container')}: ${escapeHtml(item.error || 'erro desconhecido')}`).join(' | ')} `; } function progressBar(percent) { const normalized = Math.max(0, Math.min(100, Number(percent) || 0)); return `
`; } function renderRunProgress(profileId) { const run = state.activeRuns.get(profileId); // Atualiza TODOS os elementos com o atributo (pode existir na aba Profiles E na aba Backups). const hosts = document.querySelectorAll(`[data-run-progress="${CSS.escape(profileId)}"]`); if (!hosts.length) { return; } if (!run || !run.progress) { for (const host of hosts) { 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'; const progressHtml = `
${escapeHtml(operationTitle)} ${escapeHtml(run.status)}
Containers: ${escapeHtml(String(overall.completed))}/${escapeHtml(String(overall.total))} concluido(s) Faltam ${escapeHtml(String(overall.pending))}
${progressBar(overall.percent)}
Container atual: ${escapeHtml(currentContainer?.containerName || currentContainer?.containerId || '-')} ${escapeHtml(String(Math.round(containerPercent)))}%
${progressBar(containerPercent)}
Arquivos: ${escapeHtml(String(file.current || 0))}/${escapeHtml(String(file.total || 0))} ${escapeHtml(String(Math.round(file.percent || 0)))}%
${progressBar(file.percent || 0)} Etapa: ${escapeHtml(stepLabel)} · ${escapeHtml(file.currentFile || stepMessage)}
Log detalhado ${escapeHtml(String(logs.length))} evento(s)
${logs.length ? logs.map((line) => `${escapeHtml(line)}`).join('') : 'Nenhum evento detalhado ainda.'}
`; for (const host of hosts) { host.classList.remove('hidden'); host.innerHTML = progressHtml; } } 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 '

Nenhum backup executado ainda.

'; } return `
${backups.map((backup) => ` `).join('')}
`; } async function renderProfiles() { elements.profileCount.textContent = String(state.profiles.length); if (!state.profiles.length) { elements.profilesList.innerHTML = '

Nenhum profile salvo.

'; 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) => `

${escapeHtml(profile.name)}

${escapeHtml(String(profile.containerIds.length))} container(es) · ${escapeHtml(getProfileScopeLabel(profile.backupScope))}

${escapeHtml(profile.backupDir)}
${backupButtons(profile)}
${profile.containerIds.map((containerId) => { const container = state.containers.find((item) => item.id === containerId); return `${escapeHtml(container ? container.name : containerId.slice(0, 12))}`; }).join('')}

Restaurar

${restoreButtons(profile, backupMap.get(profile.id) || [])}
`).join(''); renderAllRunProgress(); } function resetForm() { elements.profileForm.reset(); elements.profileId.value = ''; state.volumeSelections = {}; renderContainers(); closeProfileModal(); } function fillForm(profile) { elements.profileId.value = profile.id; elements.profileName.value = profile.name; populateStorageLocationDropdown(); populateSourceDropdown(); if (profile.storageLocationId) { elements.storageLocationSelect.value = profile.storageLocationId; } if (elements.profileSourceSelect) { elements.profileSourceSelect.value = profile.sourceId || ''; } const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes'; document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true; state.volumeSelections = Object.assign({}, profile.volumeSelections || {}); renderContainers(); for (const containerId of profile.containerIds) { const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`); if (input) { input.checked = true; } } openProfileModal('Editar Profile'); } 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 selectedContainerIds = getSelectedContainerIds(); const backupScope = document.querySelector('input[name="backupScope"]:checked').value; const storageLocationId = elements.storageLocationSelect.value; if (!storageLocationId) { showToast('Selecione um local de armazenamento.', true); return; } const volumeSelections = {}; if (backupScope === 'volumes') { for (const id of selectedContainerIds) { if (state.volumeSelections[id]?.length) { volumeSelections[id] = state.volumeSelections[id]; } } } const payload = { id: elements.profileId.value || undefined, name: elements.profileName.value, storageLocationId, sourceId: elements.profileSourceSelect?.value || undefined, containerIds: selectedContainerIds, backupScope, volumeSelections, }; try { await api('/api/profiles', { method: 'POST', body: JSON.stringify(payload), }); closeProfileModal(); 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); let basedOnFullBackupId = null; if (mode === 'incremental') { const result = await resolveFullBackupId(profileId, profile); if (result === undefined) { // Blocked: no full backup available button.disabled = false; button.textContent = 'Run'; return; } if (result === null) { // User cancelled modal button.disabled = false; button.textContent = 'Run'; return; } basedOnFullBackupId = result; } const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode, basedOnFullBackupId }) }); 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 (!document.querySelector('#view-backups')?.classList.contains('hidden')) { await renderBackupsView(); } 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; } } // ─── 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('')}
Nome Profile Tipo Frequência Próxima Execução Última Execução Status Ativo Açõ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'); if (!overlay) return; overlay.classList.remove('hidden'); overlay.setAttribute('aria-hidden', 'false'); document.querySelector('#logoutBtn')?.classList.add('hidden'); } function hideLoginOverlay() { const overlay = document.querySelector('#loginOverlay'); if (!overlay) return; overlay.classList.add('hidden'); overlay.setAttribute('aria-hidden', 'true'); } document.querySelector('#loginForm')?.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.querySelector('#loginUsername')?.value || ''; const password = document.querySelector('#loginPassword')?.value || ''; const errorEl = document.querySelector('#loginError'); try { const result = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const data = await result.json(); if (!result.ok) { errorEl?.classList.remove('hidden'); return; } errorEl?.classList.add('hidden'); authToken = data.token; if (authToken) localStorage.setItem('authToken', authToken); hideLoginOverlay(); document.querySelector('#logoutBtn')?.classList.remove('hidden'); await init(); } catch { errorEl?.classList.remove('hidden'); } }); document.querySelector('#logoutBtn')?.addEventListener('click', () => { authToken = null; localStorage.removeItem('authToken'); showLoginOverlay(); }); async function checkAuthAndInit() { try { const status = await fetch('/api/auth-status').then((r) => r.json()); if (!status.requireAuth) { hideLoginOverlay(); await init(); return; } // Auth required if (authToken) { // Try using stored token const probe = await fetch('/api/profiles', { headers: { 'x-auth-token': authToken } }); if (probe.ok) { hideLoginOverlay(); document.querySelector('#logoutBtn')?.classList.remove('hidden'); await init(); return; } // Token invalid authToken = null; localStorage.removeItem('authToken'); } showLoginOverlay(); } catch { // If health check fails, still show the app (might be first load) await init(); } } // ─── Settings ───────────────────────────────────────────── function buildLanguageSelect() { const select = document.querySelector('#settingsLanguage'); if (!select) return; select.innerHTML = Object.entries(LOCALE_NAMES).map(([code, name]) => `` ).join(''); select.value = currentLang; } async function loadSettingsView() { buildLanguageSelect(); const themeSelect = document.querySelector('#settingsTheme'); if (themeSelect) themeSelect.value = localStorage.getItem('theme') || 'default'; try { const settings = await api('/api/settings'); const select = document.querySelector('#settingsLanguage'); if (select && settings.language) select.value = settings.language; const authCheck = document.querySelector('#settingsRequireAuth'); if (authCheck) authCheck.checked = settings.requireAuth; const authFields = document.querySelector('#authFields'); if (authFields) authFields.classList.toggle('hidden', !settings.requireAuth); const usernameField = document.querySelector('#settingsUsername'); if (usernameField) usernameField.value = settings.username || ''; } catch { // Non-fatal: use defaults } } document.querySelector('#settingsRequireAuth')?.addEventListener('change', (e) => { document.querySelector('#authFields')?.classList.toggle('hidden', !e.target.checked); }); document.querySelector('#saveSettingsBtn')?.addEventListener('click', async () => { const language = document.querySelector('#settingsLanguage')?.value || currentLang; const requireAuth = document.querySelector('#settingsRequireAuth')?.checked || false; const username = document.querySelector('#settingsUsername')?.value?.trim() || ''; const password = document.querySelector('#settingsPassword')?.value || ''; try { await api('/api/settings', { method: 'POST', body: JSON.stringify({ language, requireAuth, username, password: password || undefined }), }); currentLang = language; localStorage.setItem('lang', language); applyTranslations(); showToast(t('settings.saved')); } catch (error) { showToast(error.message, true); } }); // ─── About ──────────────────────────────────────────────── function markdownInline(raw) { return raw .replace(/&/g, '&').replace(//g, '>') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1'); } function parseChangelogSection(markdown, maxEntries = 4) { // Extract from ## Changelog (or ## 🗂 Changelog etc.) to next ## const start = markdown.search(/^##\s+[^\n]*[Cc]hangelog/m); if (start === -1) return null; const rest = markdown.slice(start); const nextSection = rest.slice(1).search(/^## /m); const section = nextSection === -1 ? rest : rest.slice(0, nextSection + 1); const lines = section.split('\n'); let html = ''; let inList = false; let entryCount = 0; for (const line of lines) { const h3 = line.match(/^###\s+(.+)/); const h4 = line.match(/^####\s+(.+)/); const li = line.match(/^-\s+(.+)/); if (h3) { if (entryCount >= maxEntries) break; if (inList) { html += ''; inList = false; } html += `

${markdownInline(h3[1].replace(/\[([^\]]+)\]/, '$1'))}

`; entryCount += 1; } else if (h4) { if (entryCount > maxEntries) break; if (inList) { html += ''; inList = false; } html += `
${markdownInline(h4[1])}
`; } else if (li) { if (entryCount > maxEntries) break; if (!inList) { html += ''; inList = false; } } } if (inList) html += ''; return html || null; } async function loadAboutView() { const currentVerEl = document.querySelector('#aboutCurrentVersion'); const latestVerEl = document.querySelector('#aboutLatestVersion'); const updateWrap = document.querySelector('#aboutUpdateWrap'); const updateStatus = document.querySelector('#aboutUpdateStatus'); const updateBtn = document.querySelector('#aboutUpdateBtn'); const changelogEl = document.querySelector('#aboutChangelog'); if (latestVerEl) latestVerEl.textContent = t('about.checking'); // Fetch version info and changelog in parallel const [aboutResult, changelogResult] = await Promise.allSettled([ api('/api/about'), fetch('https://raw.githubusercontent.com/asabino2/dockerbackup/main/README.md').then((r) => r.text()), ]); // Version info if (aboutResult.status === 'fulfilled') { const about = aboutResult.value; const current = about.currentVersion || '—'; const latest = about.latestVersion || null; if (currentVerEl) currentVerEl.textContent = current; if (latestVerEl) latestVerEl.textContent = latest || '—'; if (updateWrap) updateWrap.classList.remove('hidden'); if (latest && current !== latest) { if (updateStatus) updateStatus.textContent = t('about.updateAvailable'); if (updateBtn) updateBtn.classList.remove('hidden'); } else if (latest) { if (updateStatus) updateStatus.textContent = t('about.upToDate'); if (updateBtn) updateBtn.classList.add('hidden'); } else { if (updateStatus) updateStatus.textContent = t('about.checkError'); if (updateBtn) updateBtn.classList.add('hidden'); } } else { if (currentVerEl) currentVerEl.textContent = '—'; if (latestVerEl) latestVerEl.textContent = '—'; showToast(aboutResult.reason?.message || 'Erro ao buscar versão', true); } // Changelog from GitHub README if (changelogEl) { if (changelogResult.status === 'fulfilled') { const html = parseChangelogSection(changelogResult.value); changelogEl.innerHTML = html || '

Changelog não encontrado.

'; } else { changelogEl.innerHTML = '

Não foi possível carregar o changelog.

'; } } } document.querySelector('#aboutUpdateBtn')?.addEventListener('click', async () => { const btn = document.querySelector('#aboutUpdateBtn'); const status = document.querySelector('#aboutUpdateStatus'); if (btn) { btn.disabled = true; btn.textContent = t('about.updating'); } try { await api('/api/update', { method: 'POST' }); if (status) status.textContent = t('about.updateSuccess'); showToast(t('about.updateSuccess')); } catch (error) { if (btn) { btn.disabled = false; btn.textContent = t('about.update'); } if (status) status.textContent = t('about.updateError'); showToast(error.message, true); } }); async function init() { applyTranslations(); try { await Promise.all([loadContainers(), loadProfiles(), loadStorageLocations(), loadSources()]); await updateDashboard(); } catch (error) { showToast(error.message, true); } } elements.profileForm.addEventListener('submit', saveProfile); elements.containerOptions.addEventListener('change', handleContainerCheck); document.querySelector('#refreshContainers').addEventListener('click', init); document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles); document.querySelector('#clearForm').addEventListener('click', resetForm); elements.profilesList.addEventListener('click', handleProfileAction); document.querySelector('#backupsViewList').addEventListener('click', handleProfileAction); document.querySelector('#refreshRuns')?.addEventListener('click', () => loadAllRuns()); // Run log modal document.querySelector('#allRunsBody')?.addEventListener('click', async (e) => { const btn = e.target.closest('.run-log-btn'); if (!btn) return; const backupId = btn.dataset.backupId; if (!backupId) return; openRunLogModal(backupId); }); document.querySelector('#runLogModalClose')?.addEventListener('click', () => { document.querySelector('#runLogModal')?.classList.add('hidden'); }); document.querySelector('#runLogModal')?.addEventListener('click', (e) => { if (e.target.dataset.action === 'close-run-log-modal') { document.querySelector('#runLogModal').classList.add('hidden'); } }); async function openRunLogModal(backupId) { const modal = document.querySelector('#runLogModal'); const content = document.querySelector('#runLogContent'); if (!modal || !content) return; modal.classList.remove('hidden'); content.innerHTML = '

Carregando log...

'; try { const backup = await api(`/api/backups/${encodeURIComponent(backupId)}`); const containers = backup.containers || []; if (!containers.length) { content.innerHTML = '

Nenhum log disponível para este backup.

'; return; } let html = ''; for (const c of containers) { const name = escapeHtml(c.containerName || c.containerId || '?'); const status = escapeHtml(c.status || '—'); html += `
`; html += `

${name} ${status}

`; if (c.error) { html += `
Erro: ${escapeHtml(c.error)}
`; } const logs = c.logs || []; if (logs.length) { html += `
${logs.map((l) => escapeHtml(l)).join('\n')}
`; } else { html += `

Sem log detalhado (backup pode ter sido criado antes desta versão).

`; } html += `
`; } content.innerHTML = html; } catch (err) { content.innerHTML = `

Erro ao carregar log: ${escapeHtml(err.message)}

`; } } // Apply translations early (before auth check so login page is translated) applyTranslations(); checkAuthAndInit();