Update Backups

This commit is contained in:
Raven Scott
2026-05-30 21:22:57 -04:00
parent 1c6419f7ce
commit 9fd3d6753c
5 changed files with 468 additions and 271 deletions
+164 -238
View File
@@ -1,219 +1,132 @@
// Backups UI functions
let backupsData = [];
let filteredBackups = [];
function formatBackupBytes(bytes) {
if (window.sdk?.utils?.format?.formatBytes) {
return window.sdk.utils.format.formatBytes(bytes || 0);
}
const n = Number(bytes) || 0;
if (n === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let value = n;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${value.toFixed(i ? 1 : 0)} ${units[i]}`;
}
// Fetch backups from API
async function fetchBackups() {
try {
const response = await fetch('/api/backups');
if (!response.ok) {
throw new Error('Failed to fetch backups');
function computeBackupStats(data) {
const backups = Array.isArray(data) ? data : [];
let totalSize = 0;
let latest = null;
for (const backup of backups) {
totalSize += backup.size || 0;
const ts = backup.timestamp ? new Date(backup.timestamp).getTime() : 0;
if (!latest || ts > latest.ts) {
latest = {
ts,
label: backup.timestamp ? new Date(backup.timestamp).toLocaleString() : '—',
files: backup.files ? backup.files.length : 0
};
}
}
return {
total: backups.length,
totalSize,
totalSizeFormatted: formatBackupBytes(totalSize),
latestLabel: latest ? latest.label : '—',
latestFiles: latest ? latest.files : '—'
};
}
function updateBackupsStats(data) {
const stats = computeBackupStats(data);
const setStat = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
setStat('backupsStatTotal', stats.total.toLocaleString());
setStat('backupsStatSize', stats.totalSizeFormatted);
setStat('backupsStatLatest', stats.latestLabel);
setStat('backupsStatFiles', stats.latestFiles === '—' ? '—' : stats.latestFiles.toLocaleString());
const overviewEl = document.getElementById('backupsOverviewLine');
if (overviewEl) {
if (stats.total === 0) {
overviewEl.textContent = 'No archives stored';
} else {
overviewEl.textContent = `${stats.total.toLocaleString()} backup${stats.total === 1 ? '' : 's'} · ${stats.totalSizeFormatted}`;
}
backupsData = await response.json();
filteredBackups = backupsData;
return backupsData;
} catch (err) {
console.error('Failed to fetch backups:', err);
if (window.showNotification) window.showNotification('Failed to load backups', 'error');
return [];
}
}
// Render backups list
function updateBackupsChrome(filteredCount) {
const totalCount = (window.backupsData || []).length;
const visibleCount = filteredCount != null
? filteredCount
: (window.filteredBackups || window.backupsData || []).length;
const searchEl = document.getElementById('search-backups');
const hasSearch = Boolean(searchEl?.value?.trim());
const trulyEmpty = totalCount === 0;
const filteredToZero = !trulyEmpty && visibleCount === 0 && hasSearch;
const emptyEl = document.getElementById('backupsEmpty');
const filteredEmptyEl = document.getElementById('backupsFilteredEmpty');
const endEl = document.getElementById('backupsEnd');
const tableEl = document.querySelector('.backups-table');
const countEl = document.getElementById('backupsCount');
if (emptyEl) emptyEl.classList.toggle('local-dns-empty--visible', trulyEmpty);
if (filteredEmptyEl) filteredEmptyEl.classList.toggle('local-dns-empty--visible', filteredToZero);
if (tableEl) tableEl.classList.toggle('backups-table--hidden', trulyEmpty || filteredToZero);
if (endEl) endEl.classList.remove('local-dns-list-end--visible');
if (countEl) {
if (trulyEmpty) {
countEl.textContent = 'No backups yet';
} else if (hasSearch && visibleCount !== totalCount) {
countEl.textContent = `${visibleCount.toLocaleString()} of ${totalCount.toLocaleString()} shown`;
} else {
countEl.textContent = `${totalCount.toLocaleString()} backup${totalCount === 1 ? '' : 's'}`;
}
}
if (window.backupsData) {
updateBackupsStats(window.backupsData);
}
}
function showBackupsListEnd() {
const endEl = document.getElementById('backupsEnd');
const totalCount = (window.filteredBackups || window.backupsData || []).length;
const visibleCount = totalCount;
if (endEl && totalCount > 0 && visibleCount > 0) {
endEl.classList.add('local-dns-list-end--visible');
}
}
async function renderBackups() {
await fetchBackups();
filterBackups();
}
// Filter backups
function filterBackups() {
const searchEl = document.getElementById('search-backups');
if (!searchEl) return;
const query = searchEl.value.toLowerCase();
filteredBackups = backupsData.filter(backup =>
backup.name.toLowerCase().includes(query) ||
backup.timestamp.toLowerCase().includes(query) ||
(backup.version && backup.version.toLowerCase().includes(query))
);
// Reset infinite scroll state when filtering
if (!window.infiniteScrollState) {
window.infiniteScrollState = {};
}
if (window.infiniteScrollState.backups) {
window.infiniteScrollState.backups.loadedCount = 0;
window.infiniteScrollState.backups.lastQuery = query;
}
renderBackupsTable();
}
// Render backups table with infinite scroll
function renderBackupsTable() {
const container = document.getElementById('backupsTable');
if (!container) return;
// Initialize infinite scroll state
if (!window.infiniteScrollState) {
window.infiniteScrollState = {};
}
if (!window.infiniteScrollState.backups) {
window.infiniteScrollState.backups = {
loadedCount: 0,
observer: null,
batchSize: 15,
lastQuery: ''
};
}
const state = window.infiniteScrollState.backups;
const searchEl = document.getElementById('search-backups');
const query = searchEl ? searchEl.value.toLowerCase() : '';
const isNewSearch = state.lastQuery !== query;
// Reset if new search
if (isNewSearch) {
state.loadedCount = 0;
state.lastQuery = query;
container.innerHTML = '';
// Disconnect existing observer
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
}
// Handle empty state
if (filteredBackups.length === 0) {
container.innerHTML = '<tr><td colspan="6" class="p-4 text-center theme-text-tertiary">No backups found</td></tr>';
return;
}
// Load next batch
loadBackupsBatch();
}
// Load next batch of backups
function loadBackupsBatch() {
const container = document.getElementById('backupsTable');
if (!container) return;
const state = window.infiniteScrollState.backups;
if (!state) return;
const start = state.loadedCount;
const end = Math.min(start + state.batchSize, filteredBackups.length);
const batch = filteredBackups.slice(start, end);
if (batch.length === 0) {
// No more data to load
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
// Remove sentinel if exists
const sentinel = container.querySelector('.infinite-scroll-sentinel');
if (sentinel) sentinel.remove();
return;
}
// Render batch
batch.forEach(backup => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
const date = new Date(backup.timestamp);
const dateStr = date.toLocaleString();
tr.innerHTML = `
<td class="p-3">${backup.name}</td>
<td class="p-3">${dateStr}</td>
<td class="p-3">${backup.sizeFormatted || '0 B'}</td>
<td class="p-3">${backup.files ? backup.files.length : 0}</td>
<td class="p-3">${backup.version || 'unknown'}</td>
<td class="p-3">
<button onclick="viewBackupDetails('${backup.name}')" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 mr-2">Details</button>
<button id="restore-btn-${backup.name.replace(/[^a-zA-Z0-9]/g, '-')}" onclick="restoreBackup('${backup.name}', this)" class="px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600 mr-2">Restore</button>
<button id="delete-btn-${backup.name.replace(/[^a-zA-Z0-9]/g, '-')}" onclick="deleteBackup('${backup.name}', this)" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
</td>
`;
container.appendChild(tr);
});
state.loadedCount = end;
// Setup IntersectionObserver for next batch
if (end < filteredBackups.length) {
setupBackupsObserver(container);
} else {
// All data loaded, disconnect observer
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
// Remove sentinel if exists
const sentinel = container.querySelector('.infinite-scroll-sentinel');
if (sentinel) sentinel.remove();
if (window.genericFetch) {
await window.genericFetch('backups');
}
}
// Setup IntersectionObserver for backups
function setupBackupsObserver(container) {
const state = window.infiniteScrollState.backups;
if (!state) return;
// Create or get sentinel element
let sentinel = container.querySelector('.infinite-scroll-sentinel');
if (!sentinel) {
sentinel = document.createElement('tr');
sentinel.className = 'infinite-scroll-sentinel';
sentinel.innerHTML = '<td colspan="6" style="height: 1px; padding: 0;"></td>';
container.appendChild(sentinel);
}
// Find the scrollable container (must be a parent with overflow-y-auto or overflow-auto)
const scrollContainer = container.closest('.overflow-y-auto, .overflow-auto');
if (!scrollContainer) {
console.warn('No scrollable container found for backups');
return;
}
// Disconnect existing observer
if (state.observer) {
state.observer.disconnect();
}
state.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadBackupsBatch();
}
});
}, {
root: scrollContainer,
rootMargin: '200px'
});
state.observer.observe(sentinel);
}
// Pagination function removed - using infinite scroll instead
// Create backup
async function createBackup(buttonElement) {
if (!buttonElement) return;
if (window.showConfirm) {
window.showConfirm('Create a new backup? This may take a moment.', async () => {
const originalText = buttonElement.innerHTML;
const originalHtml = buttonElement.innerHTML;
buttonElement.disabled = true;
buttonElement.innerHTML = 'Creating... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span>';
buttonElement.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> Creating…';
try {
const response = await fetch('/api/backups/create', {
method: 'POST'
});
const response = await fetch('/api/backups/create', { method: 'POST' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create backup');
@@ -225,19 +138,18 @@ async function createBackup(buttonElement) {
if (window.showNotification) window.showNotification('Failed to create backup: ' + err.message, 'error');
} finally {
buttonElement.disabled = false;
buttonElement.innerHTML = originalText;
buttonElement.innerHTML = originalHtml;
}
});
}
}
// Restore backup
async function restoreBackup(backupName, buttonElement) {
if (!buttonElement) return;
if (!buttonElement || !backupName) return;
const originalText = buttonElement.innerHTML;
const originalHtml = buttonElement.innerHTML;
buttonElement.disabled = true;
buttonElement.innerHTML = 'Restoring... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span>';
buttonElement.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
try {
const response = await fetch('/api/backups/restore', {
@@ -249,9 +161,10 @@ async function restoreBackup(backupName, buttonElement) {
const error = await response.json();
throw new Error(error.error || 'Failed to restore backup');
}
if (window.showNotification) window.showNotification('Backup restored successfully. System may need to refresh.', 'success');
if (window.showNotification) {
window.showNotification('Backup restored successfully. System may need to refresh.', 'success');
}
await renderBackups();
// Optionally reload after restore
setTimeout(() => {
if (window.showConfirm) {
window.showConfirm('Reload page to see restored data?', () => {
@@ -264,19 +177,18 @@ async function restoreBackup(backupName, buttonElement) {
if (window.showNotification) window.showNotification('Failed to restore backup: ' + err.message, 'error');
} finally {
buttonElement.disabled = false;
buttonElement.innerHTML = originalText;
buttonElement.innerHTML = originalHtml;
}
}
// Delete backup
async function deleteBackup(backupName, buttonElement) {
if (!buttonElement) return;
if (!buttonElement || !backupName) return;
if (window.showConfirm) {
window.showConfirm(`Delete backup "${backupName}"? This action cannot be undone.`, async () => {
const originalText = buttonElement.innerHTML;
const originalHtml = buttonElement.innerHTML;
buttonElement.disabled = true;
buttonElement.innerHTML = 'Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span>';
buttonElement.innerHTML = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>';
try {
const response = await fetch(`/api/backups/${encodeURIComponent(backupName)}`, {
@@ -287,56 +199,70 @@ async function deleteBackup(backupName, buttonElement) {
throw new Error(error.error || 'Failed to delete backup');
}
if (window.showNotification) window.showNotification('Backup deleted successfully');
// Re-fetch and re-render backups
await renderBackups();
} catch (err) {
console.error('Failed to delete backup:', err);
if (window.showNotification) window.showNotification('Failed to delete backup: ' + err.message, 'error');
} finally {
buttonElement.disabled = false;
buttonElement.innerHTML = originalText;
buttonElement.innerHTML = originalHtml;
}
});
}
}
// View backup details
async function viewBackupDetails(backupName) {
if (!backupName) return;
try {
const response = await fetch(`/api/backups/${encodeURIComponent(backupName)}/metadata`);
if (!response.ok) {
throw new Error('Failed to fetch backup details');
}
const metadata = await response.json();
const esc = window.escapeHtml || ((value) => String(value));
const modal = document.getElementById('backupDetailsModal');
if (!modal) return;
const subtitle = document.getElementById('backup-details-subtitle');
if (subtitle) subtitle.textContent = metadata.name || backupName;
const content = document.getElementById('backup-details-content');
if (content) {
const filesList = metadata.files.map(file =>
`<div class="mb-2 p-2 bg-gray-100 dark:bg-gray-700 rounded">
<div class="font-semibold">${file.name}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Size: ${file.sizeFormatted} | Modified: ${file.modified ? new Date(file.modified).toLocaleString() : 'N/A'}</div>
</div>`
).join('');
const timestamp = metadata.timestamp ? new Date(metadata.timestamp).toLocaleString() : '—';
const fileCount = metadata.files ? metadata.files.length : 0;
const filesList = (metadata.files || []).map((file) => {
const modified = file.modified ? new Date(file.modified).toLocaleString() : 'N/A';
return `<li class="backups-file-item">
<span class="backups-file-name">${esc(file.name)}</span>
<span class="backups-file-meta theme-text-tertiary">${esc(file.sizeFormatted || '—')} · ${esc(modified)}</span>
</li>`;
}).join('');
content.innerHTML = `
<div class="mb-4">
<h4 class="font-semibold mb-2">Backup Information</h4>
<p><strong>Name:</strong> ${metadata.name || backupName}</p>
<p><strong>Timestamp:</strong> ${new Date(metadata.timestamp).toLocaleString()}</p>
<p><strong>Version:</strong> ${metadata.version || 'unknown'}</p>
<p><strong>Files:</strong> ${metadata.files ? metadata.files.length : 0}</p>
</div>
<div>
<h4 class="font-semibold mb-2">Files in Backup</h4>
${filesList || '<p class="text-gray-500">No files found</p>'}
</div>
`;
<dl class="backups-details-grid">
<div class="backups-details-field">
<dt>Timestamp</dt>
<dd>${esc(timestamp)}</dd>
</div>
<div class="backups-details-field">
<dt>Version</dt>
<dd><span class="backups-version-badge">${esc(metadata.version || 'unknown')}</span></dd>
</div>
<div class="backups-details-field">
<dt>Files</dt>
<dd>${fileCount.toLocaleString()}</dd>
</div>
</dl>
<div class="backups-files-section">
<h4 class="backups-files-title">Files in backup</h4>
${filesList
? `<ul class="backups-files-list">${filesList}</ul>`
: '<p class="backups-files-empty theme-text-tertiary">No files found in metadata.</p>'}
</div>`;
}
modal.showModal();
} catch (err) {
console.error('Failed to fetch backup details:', err);
@@ -344,11 +270,11 @@ async function viewBackupDetails(backupName) {
}
}
// Make functions globally accessible
window.renderBackups = renderBackups;
window.createBackup = createBackup;
window.restoreBackup = restoreBackup;
window.deleteBackup = deleteBackup;
window.viewBackupDetails = viewBackupDetails;
window.filterBackups = filterBackups;
window.updateBackupsChrome = updateBackupsChrome;
window.updateBackupsStats = updateBackupsStats;
window.showBackupsListEnd = showBackupsListEnd;
+49 -2
View File
@@ -658,8 +658,55 @@ window.tabs = {
filteredKey: 'filteredBackups',
containerId: 'backupsTable',
paginationId: 'backupsPagination',
sentinelId: 'backupsScrollSentinel',
sort: (a, b) => new Date(b.timestamp) - new Date(a.timestamp),
filter: (item, query) => item.name.toLowerCase().includes(query) || item.timestamp.toLowerCase().includes(query),
renderItem: null // Custom renderer in backups.js
filter: (item, query) => {
const queryLower = query.toLowerCase();
return (
item.name.toLowerCase().includes(queryLower) ||
item.timestamp.toLowerCase().includes(queryLower) ||
(item.version && item.version.toLowerCase().includes(queryLower))
);
},
renderItem: (backup) => {
const esc = window.escapeHtml || ((value) => String(value));
const name = backup.name || '';
const nameAttr = esc(name).replace(/"/g, '&quot;');
const slug = name.replace(/[^a-zA-Z0-9]/g, '-') || 'backup';
const dateStr = backup.timestamp ? new Date(backup.timestamp).toLocaleString() : '—';
const fileCount = backup.files ? backup.files.length : 0;
const tr = document.createElement('tr');
tr.className = 'backups-row';
tr.innerHTML = `
<td class="p-3"><code class="backups-name">${esc(name)}</code></td>
<td class="p-3 backups-timestamp tabular-nums">${esc(dateStr)}</td>
<td class="p-3 tabular-nums">${esc(backup.sizeFormatted || '0 B')}</td>
<td class="p-3 tabular-nums">${fileCount}</td>
<td class="p-3"><span class="backups-version-badge">${esc(backup.version || 'unknown')}</span></td>
<td class="p-3 text-right">
<div class="backups-row-actions">
<button type="button" class="admin-btn admin-btn--secondary admin-btn--sm" data-backup-name="${nameAttr}" onclick="viewBackupDetails(this.getAttribute('data-backup-name'))" title="View details">
<i class="fas fa-circle-info" aria-hidden="true"></i>
</button>
<button type="button" id="restore-btn-${slug}" class="admin-btn admin-btn--success admin-btn--sm" data-backup-name="${nameAttr}" onclick="restoreBackup(this.getAttribute('data-backup-name'), this)" title="Restore backup">
<i class="fas fa-rotate-left" aria-hidden="true"></i>
</button>
<button type="button" id="delete-btn-${slug}" class="admin-btn admin-btn--danger admin-btn--sm" data-backup-name="${nameAttr}" onclick="deleteBackup(this.getAttribute('data-backup-name'), this)" title="Delete backup">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</td>`;
return tr;
},
preRender: (visibleCount) => {
if (window.updateBackupsChrome) window.updateBackupsChrome(visibleCount);
},
onAllItemsLoaded: () => {
if (window.showBackupsListEnd) window.showBackupsListEnd();
},
postFetch: (data) => {
if (window.updateBackupsStats) window.updateBackupsStats(data);
return data;
}
}
};
+2
View File
@@ -441,6 +441,7 @@ function filterInterfaces() { genericFilter('interfaces'); }
function filterLocalDNS() { genericFilter('local-dns'); }
function filterDnsConflicts() { genericFilter('dns-conflicts'); }
function filterP2pDomainConflicts() { genericFilter('p2p-domain-conflicts'); }
function filterBackups() { genericFilter('backups'); }
function filterHolesailServers() { genericFilter('host-servers'); }
function filterHolesailClients() { genericFilter('host-clients'); }
@@ -457,6 +458,7 @@ window.filterInterfaces = filterInterfaces;
window.filterLocalDNS = filterLocalDNS;
window.filterDnsConflicts = filterDnsConflicts;
window.filterP2pDomainConflicts = filterP2pDomainConflicts;
window.filterBackups = filterBackups;
window.filterHolesailServers = filterHolesailServers;
window.filterHolesailClients = filterHolesailClients;