Update Backups
This commit is contained in:
@@ -846,30 +846,94 @@
|
||||
</div>
|
||||
|
||||
<div id="backups" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Backup & Restore
|
||||
<button onclick="openInfoModal('backups')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
</h2>
|
||||
<div class="mb-6 flex-shrink-0">
|
||||
<input id="search-backups" type="text" placeholder="Search backups..." class="w-full p-3 rounded-lg theme-input focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterBackups()">
|
||||
<header class="admin-tab-header flex-shrink-0">
|
||||
<div class="admin-tab-header-main">
|
||||
<h2 class="admin-tab-title">Backup & Restore</h2>
|
||||
<p class="admin-tab-description">Create snapshots of configuration and data, restore previous states, and manage retention on this node.</p>
|
||||
</div>
|
||||
<button type="button" onclick="openInfoModal('backups')" class="theme-button-info text-sm px-3 py-1 rounded">Info</button>
|
||||
</header>
|
||||
|
||||
<div class="backups-layout flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="theme-card admin-panel backups-panel lg:col-span-2 flex flex-col min-h-0">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Backup archive</h3>
|
||||
<p class="admin-panel-subtitle" id="backupsCount">Loading…</p>
|
||||
</div>
|
||||
<div class="admin-toolbar flex-shrink-0">
|
||||
<label class="admin-search-field">
|
||||
<i class="fas fa-magnifying-glass admin-search-icon" aria-hidden="true"></i>
|
||||
<input id="search-backups" type="search" placeholder="Search by name, date, or version…" class="theme-input admin-search-input" oninput="filterBackups()" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<div class="backups-table-scroll flex-1 min-h-0 overflow-auto">
|
||||
<table class="w-full theme-table backups-table">
|
||||
<thead class="theme-table thead sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Name</th>
|
||||
<th class="p-3 text-left w-[11rem]">Timestamp</th>
|
||||
<th class="p-3 text-left w-[6rem]">Size</th>
|
||||
<th class="p-3 text-left w-[4.5rem]">Files</th>
|
||||
<th class="p-3 text-left w-[5.5rem]">Version</th>
|
||||
<th class="p-3 text-right w-[8.5rem]"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="backupsTable"></tbody>
|
||||
</table>
|
||||
<div id="backupsEmpty" class="local-dns-empty">
|
||||
<i class="fas fa-database local-dns-empty-icon" aria-hidden="true"></i>
|
||||
<p class="local-dns-empty-title">No backups yet</p>
|
||||
<p class="local-dns-empty-text theme-text-tertiary">Create a manual backup or wait for the next scheduled snapshot.</p>
|
||||
</div>
|
||||
<div id="backupsFilteredEmpty" class="local-dns-empty">
|
||||
<i class="fas fa-filter local-dns-empty-icon" aria-hidden="true"></i>
|
||||
<p class="local-dns-empty-title">No matching backups</p>
|
||||
<p class="local-dns-empty-text theme-text-tertiary">Try a different search term.</p>
|
||||
</div>
|
||||
<div id="backupsEnd" class="local-dns-list-end" aria-live="polite">You have reached the end</div>
|
||||
<div id="backupsScrollSentinel" class="scroll-sentinel"></div>
|
||||
</div>
|
||||
<div id="backupsPagination" class="hidden"></div>
|
||||
<div class="admin-panel-footer flex justify-end flex-shrink-0">
|
||||
<button type="button" id="create-backup-btn" onclick="createBackup(this)" class="admin-btn admin-btn--primary">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> Create backup
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="backups-sidebar flex flex-col gap-4 min-h-0">
|
||||
<section class="theme-card admin-panel backups-summary-panel">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Overview</h3>
|
||||
<p class="admin-panel-subtitle" id="backupsOverviewLine">—</p>
|
||||
</div>
|
||||
<dl class="local-dns-stat-grid">
|
||||
<div class="local-dns-stat">
|
||||
<dt>Total</dt>
|
||||
<dd id="backupsStatTotal">—</dd>
|
||||
</div>
|
||||
<div class="local-dns-stat">
|
||||
<dt>Total size</dt>
|
||||
<dd id="backupsStatSize">—</dd>
|
||||
</div>
|
||||
<div class="local-dns-stat">
|
||||
<dt>Latest</dt>
|
||||
<dd id="backupsStatLatest" class="backups-stat-latest">—</dd>
|
||||
</div>
|
||||
<div class="local-dns-stat">
|
||||
<dt>Files (latest)</dt>
|
||||
<dd id="backupsStatFiles">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="theme-card admin-panel backups-note-panel">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">About backups</h3>
|
||||
</div>
|
||||
<p class="local-dns-note-text theme-text-tertiary">Automatic backups run on a schedule configured in <button type="button" class="backups-settings-link" onclick="if(window.navigateToTab) window.navigateToTab('settings'); else location.hash = 'settings';">Settings</button>. Old archives are pruned based on retention limits.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="overflow-x-auto overflow-y-auto flex-1 min-h-0">
|
||||
<table class="w-full theme-table rounded-lg">
|
||||
<thead class="theme-table thead sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Name</th>
|
||||
<th class="p-3 text-left">Timestamp</th>
|
||||
<th class="p-3 text-left">Size</th>
|
||||
<th class="p-3 text-left">Files</th>
|
||||
<th class="p-3 text-left">Version</th>
|
||||
<th class="p-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="backupsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="backupsPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
|
||||
<button id="create-backup-btn" onclick="createBackup(this)" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover flex-shrink-0">Create Backup</button>
|
||||
</div>
|
||||
|
||||
<div id="stats" class="tab-content hidden admin-tab-panel--scroll stats-tab">
|
||||
@@ -1395,14 +1459,21 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="backupDetailsModal" class="p-6 theme-glass rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-2xl font-bold">Backup Details</h3>
|
||||
<button onclick="document.getElementById('backupDetailsModal').close()" class="theme-text-tertiary hover:text-gray-700 dark:hover:text-gray-200 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
<div id="backup-details-content" class="mb-6"></div>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button onclick="document.getElementById('backupDetailsModal').close()" class="px-4 py-2 theme-button-info theme-text-primary rounded theme-glass-hover">Close</button>
|
||||
<dialog id="backupDetailsModal" class="backups-modal local-dns-modal theme-glass rounded-lg shadow-xl w-full max-w-3xl p-0 border-0">
|
||||
<div class="local-dns-modal-inner backups-modal-inner">
|
||||
<div class="local-dns-modal-header">
|
||||
<div>
|
||||
<h3 class="local-dns-modal-title">Backup details</h3>
|
||||
<p id="backup-details-subtitle" class="local-dns-modal-subtitle theme-text-tertiary font-mono break-all">—</p>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('backupDetailsModal').close()" class="admin-btn admin-btn--secondary admin-btn--sm" aria-label="Close">
|
||||
<i class="fas fa-xmark" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="backup-details-content" class="local-dns-modal-body backups-modal-body"></div>
|
||||
<div class="local-dns-modal-footer">
|
||||
<button type="button" onclick="document.getElementById('backupDetailsModal').close()" class="admin-btn admin-btn--secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -3938,6 +3938,157 @@ pre.theme-glass, code.theme-glass {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Backups tab */
|
||||
.backups-layout {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.backups-panel {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.backups-table-scroll {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.backups-table--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backups-name {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.backups-timestamp {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.backups-version-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--admin-bg-glass);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.backups-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backups-stat-latest {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.backups-settings-link {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--admin-brand-light);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.backups-settings-link:hover {
|
||||
color: var(--admin-brand);
|
||||
}
|
||||
|
||||
.backups-modal {
|
||||
max-height: min(90vh, 40rem);
|
||||
}
|
||||
|
||||
.backups-modal-inner {
|
||||
max-height: min(90vh, 40rem);
|
||||
}
|
||||
|
||||
.backups-modal-body {
|
||||
max-height: min(60vh, 28rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.backups-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.backups-details-field dt {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.backups-details-field dd {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.backups-files-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.backups-files-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.backups-files-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.backups-file-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--admin-bg-glass);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.backups-file-name {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.backups-file-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.backups-files-empty {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entries-sidebar {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '"');
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user