922 lines
48 KiB
JavaScript
922 lines
48 KiB
JavaScript
// ========== UNIFIED BACKUP/RESTORE v2.0 ==========
|
||
// Single file captures everything: server config + browser state + themes + encryption key
|
||
(function() {
|
||
// All DashCaddy localStorage keys to include in backup
|
||
var BROWSER_STATE_KEYS = [
|
||
'dashcaddy_site_config', 'dashcaddy_onboarding', 'dashcaddy-encryption-key',
|
||
'dashcaddy-setup', 'dashcaddy-config',
|
||
'theme', 'user-themes', 'custom-theme',
|
||
'custom-apps', 'custom-services', 'toolbar-sections',
|
||
'weather-location', 'weather-zip', 'weather-geo', 'weather-unit',
|
||
'clock-style', 'clock-chimes', 'clock-chime-volume'
|
||
];
|
||
|
||
// Collect all DashCaddy browser state from localStorage
|
||
function collectBrowserState() {
|
||
var state = {};
|
||
// Grab all whitelisted keys
|
||
for (var i = 0; i < BROWSER_STATE_KEYS.length; i++) {
|
||
var key = BROWSER_STATE_KEYS[i];
|
||
var val = safeGet(key);
|
||
if (val !== null && val !== undefined) state[key] = val;
|
||
}
|
||
// Grab dynamic widget-*-enabled keys
|
||
try {
|
||
for (var j = 0; j < localStorage.length; j++) {
|
||
var k = localStorage.key(j);
|
||
if (/^widget-.+-enabled$/.test(k)) {
|
||
state[k] = localStorage.getItem(k);
|
||
}
|
||
}
|
||
} catch (e) { /* private browsing */ }
|
||
return state;
|
||
}
|
||
|
||
// Restore browser state from backup into localStorage
|
||
function restoreBrowserState(browserState) {
|
||
if (!browserState || typeof browserState !== 'object') return 0;
|
||
var count = 0;
|
||
for (var key in browserState) {
|
||
if (!browserState.hasOwnProperty(key)) continue;
|
||
safeSet(key, browserState[key]);
|
||
count++;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
// Handle legacy v1.0.0 import-export format (pre-backup modal)
|
||
function isLegacyFormat(data) {
|
||
return data.version && !data.files && data.services;
|
||
}
|
||
|
||
function restoreLegacyFormat(data) {
|
||
// Map the old flat keys into browserState for localStorage restore
|
||
var browserState = {};
|
||
if (data.customServices) browserState['custom-services'] = JSON.stringify(data.customServices);
|
||
if (data.customApps) browserState['custom-apps'] = JSON.stringify(data.customApps);
|
||
if (data.weatherZip) browserState['weather-zip'] = data.weatherZip;
|
||
if (data.theme) browserState['theme'] = data.theme;
|
||
if (data.userThemes && Object.keys(data.userThemes).length) browserState['user-themes'] = JSON.stringify(data.userThemes);
|
||
restoreBrowserState(browserState);
|
||
|
||
// Push services to server if available
|
||
if (data.services && Array.isArray(data.services)) {
|
||
secureFetch('/api/v1/services', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data.services)
|
||
}).catch(function() {});
|
||
}
|
||
|
||
// Push themes to server if available
|
||
if (data.userThemes) {
|
||
Object.keys(data.userThemes).forEach(function(slug) {
|
||
var t = data.userThemes[slug];
|
||
var colors = {};
|
||
(window.THEME_PROPS || []).forEach(function(p) { if (t[p]) colors[p] = t[p]; });
|
||
secureFetch('/api/v1/themes/' + slug, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: t.name || slug, colors: colors })
|
||
}).catch(function() {});
|
||
});
|
||
}
|
||
}
|
||
|
||
// Inject modal HTML
|
||
injectModal('backup-modal', `<div id="backup-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||
<h3>💾 Backup & Restore</h3>
|
||
<p class="modal-subtitle">
|
||
Full backup of your entire DashCaddy setup — server config, credentials, themes, and browser preferences in one file.
|
||
</p>
|
||
|
||
<!-- Tab bar -->
|
||
<div class="panel-tabs">
|
||
<button class="panel-tab active" data-panel="backup-manual">Manual</button>
|
||
<button class="panel-tab" data-panel="backup-schedules-tab">Schedules</button>
|
||
<button class="panel-tab" data-panel="backup-disk-tab">Backups on Disk</button>
|
||
<button class="panel-tab" data-panel="backup-pointintime-tab">Point-in-Time</button>
|
||
<button class="panel-tab" data-panel="backup-history-tab">History</button>
|
||
</div>
|
||
|
||
<!-- Tab: Manual -->
|
||
<div id="backup-manual" class="panel-section active">
|
||
<!-- Export Section -->
|
||
<div style="margin-bottom: 20px; padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #27ae60 15%, transparent), color-mix(in srgb, #2ecc71 10%, transparent)); border-radius: 10px; border: 1px solid #27ae60;">
|
||
<h4 style="margin: 0 0 8px; color: #2ecc71; font-size: 0.95rem;">📤 Export Backup</h4>
|
||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
||
Downloads everything — services, Caddyfile, credentials, encryption key, themes, and all browser preferences.
|
||
</p>
|
||
<button id="backup-export-btn" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||
⬇️ Download Full Backup
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Import Section -->
|
||
<div style="padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #3498db 15%, transparent), color-mix(in srgb, #2980b9 10%, transparent)); border-radius: 10px; border: 1px solid #3498db;">
|
||
<h4 style="margin: 0 0 8px; color: #3498db; font-size: 0.95rem;">📥 Restore Backup</h4>
|
||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
||
Upload a backup file to restore your entire configuration — drag and drop ready.
|
||
</p>
|
||
<input type="file" id="backup-file-input" accept=".json" style="display: none;" />
|
||
<button id="backup-select-file" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||
📁 Select Backup File
|
||
</button>
|
||
<div id="backup-file-name" style="display: none; margin-top: 8px; padding: 8px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;"></div>
|
||
</div>
|
||
|
||
<!-- Preview Section (shown after file selected) -->
|
||
<div id="backup-preview" style="display: none; margin-top: 16px; padding: 16px; background: var(--card-base); border-radius: 10px; border: 1px solid var(--border);">
|
||
<h4 style="margin: 0 0 12px; font-size: 0.9rem;">📋 Backup Contents</h4>
|
||
<div id="backup-preview-content" style="font-size: 0.85rem;"></div>
|
||
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||
<label class="checkbox-label" style="font-size: 0.85rem;">
|
||
<input type="checkbox" id="backup-reload-caddy" checked />
|
||
Reload Caddy after restore
|
||
</label>
|
||
</div>
|
||
<button id="backup-do-restore-btn" style="width: 100%; margin-top: 12px; padding: 10px; background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||
⚡ Restore Everything
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Result Message -->
|
||
<div id="backup-result" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||
</div>
|
||
|
||
<!-- Tab: Schedules (Premium) -->
|
||
<div id="backup-schedules-tab" class="panel-section">
|
||
<div id="backup-schedules-container">
|
||
<div class="panel-empty">
|
||
<span class="empty-icon">⏰</span>
|
||
<span class="brand-spinner"></span> Loading schedules...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: Backups on Disk -->
|
||
<div id="backup-disk-tab" class="panel-section">
|
||
<div id="backup-disk-container">
|
||
<div class="panel-empty">
|
||
<span class="empty-icon">💾</span>
|
||
<span class="brand-spinner"></span> Loading backup files...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: Point-in-Time Restore -->
|
||
<div id="backup-pointintime-tab" class="panel-section">
|
||
<div id="pointintime-container">
|
||
<div class="panel-empty">
|
||
<span class="empty-icon">⏪</span>
|
||
<span class="brand-spinner"></span> Loading...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: Backup History -->
|
||
<div id="backup-history-tab" class="panel-section">
|
||
<div id="backup-history-container" style="max-height: 400px; overflow-y: auto;">
|
||
<div class="panel-empty">
|
||
<span class="empty-icon">📋</span>
|
||
<span class="brand-spinner"></span> Loading backup history...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Close Button -->
|
||
<div class="weather-modal-buttons modal-footer-bar">
|
||
<button id="backup-cancel">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>`);
|
||
|
||
var modal = document.getElementById('backup-modal');
|
||
var openBtn = document.getElementById('backup-restore-btn');
|
||
var cancelBtn = document.getElementById('backup-cancel');
|
||
var exportBtn = document.getElementById('backup-export-btn');
|
||
var selectFileBtn = document.getElementById('backup-select-file');
|
||
var fileInput = document.getElementById('backup-file-input');
|
||
var fileNameDiv = document.getElementById('backup-file-name');
|
||
var previewDiv = document.getElementById('backup-preview');
|
||
var previewContent = document.getElementById('backup-preview-content');
|
||
var restoreBtn = document.getElementById('backup-do-restore-btn');
|
||
var resultDiv = document.getElementById('backup-result');
|
||
var scheduleContainer = document.getElementById('backup-schedules-container');
|
||
var historyContainer = document.getElementById('backup-history-container');
|
||
var diskContainer = document.getElementById('backup-disk-container');
|
||
var pointintimeContainer = document.getElementById('pointintime-container');
|
||
|
||
var selectedBackup = null;
|
||
|
||
// Open modal
|
||
openBtn?.addEventListener('click', function() {
|
||
modal.classList.add('show');
|
||
if (resultDiv) resultDiv.style.display = 'none';
|
||
if (previewDiv) previewDiv.style.display = 'none';
|
||
if (fileNameDiv) fileNameDiv.style.display = 'none';
|
||
selectedBackup = null;
|
||
});
|
||
|
||
wireModal(modal, cancelBtn);
|
||
|
||
// === EXPORT: Server backup + browser state in one file ===
|
||
exportBtn?.addEventListener('click', async function() {
|
||
exportBtn.disabled = true;
|
||
exportBtn.innerHTML = '<span class="brand-spinner"></span> Exporting...';
|
||
try {
|
||
// Fetch server-side backup (config, services, caddyfile, credentials, encryption key, themes, etc.)
|
||
var response = await fetch('/api/v1/backup/export');
|
||
var data = await response.json();
|
||
|
||
// Add all browser localStorage state
|
||
data.browserState = collectBrowserState();
|
||
|
||
// Download unified backup
|
||
var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'dashcaddy-backup-' + new Date().toISOString().split('T')[0] + '.json';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
var stateCount = Object.keys(data.browserState).length;
|
||
var themeCount = data.themes ? Object.keys(data.themes).length : 0;
|
||
resultDiv.innerHTML = '✅ Full backup downloaded — server config + ' + stateCount + ' browser settings' + (themeCount ? ' + ' + themeCount + ' themes' : '');
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||
} catch (e) {
|
||
resultDiv.innerHTML = '❌ Export failed: ' + escapeHtml(e.message);
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
}
|
||
exportBtn.disabled = false;
|
||
exportBtn.innerHTML = '⬇️ Download Full Backup';
|
||
});
|
||
|
||
// Select file button
|
||
selectFileBtn?.addEventListener('click', function() { fileInput.click(); });
|
||
|
||
// === FILE SELECTED: Preview contents ===
|
||
fileInput?.addEventListener('change', async function(e) {
|
||
var file = e.target.files[0];
|
||
if (!file) return;
|
||
fileNameDiv.textContent = '📄 ' + file.name;
|
||
fileNameDiv.style.display = 'block';
|
||
resultDiv.style.display = 'none';
|
||
try {
|
||
var text = await file.text();
|
||
var backup = JSON.parse(text);
|
||
|
||
// Handle legacy v1.0.0 format (from old import-export.js)
|
||
if (isLegacyFormat(backup)) {
|
||
selectedBackup = backup;
|
||
var html = '<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v' + escapeHtml(backup.version) + ')</div>';
|
||
html += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">';
|
||
if (backup.services?.length) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">📋 ' + backup.services.length + ' services</span>';
|
||
if (backup.customApps?.length) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">📦 ' + backup.customApps.length + ' custom apps</span>';
|
||
if (backup.theme) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">🎨 Theme: ' + escapeHtml(backup.theme) + '</span>';
|
||
if (backup.userThemes) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">🎨 ' + Object.keys(backup.userThemes).length + ' custom themes</span>';
|
||
html += '</div>';
|
||
previewContent.innerHTML = html;
|
||
previewDiv.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// v1.1+ / v2.0 format — send to server for preview
|
||
var response = await secureFetch('/api/v1/backup/preview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(backup)
|
||
});
|
||
var preview = await response.json();
|
||
if (preview.success) {
|
||
selectedBackup = backup;
|
||
var html = '<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: ' + new Date(backup.exportedAt).toLocaleString() + ' (v' + escapeHtml(backup.version) + ')</div>';
|
||
|
||
// Server files
|
||
html += '<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>';
|
||
html += '<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';
|
||
for (var key in preview.preview.files) {
|
||
var info = preview.preview.files[key];
|
||
var icon = info.action === 'create' ? '🆕' : '📝';
|
||
html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">' + icon + ' ' + escapeHtml(info.description) + '</span>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Services count
|
||
if (preview.preview.serviceCount) {
|
||
html += '<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">' + preview.preview.serviceCount + ' services</div>';
|
||
}
|
||
|
||
// Themes
|
||
if (preview.preview.themeCount) {
|
||
html += '<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">🎨 ' + preview.preview.themeCount + ' custom themes</div>';
|
||
}
|
||
|
||
// Browser state
|
||
if (preview.preview.browserStateCount) {
|
||
html += '<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>';
|
||
html += '<div style="font-size: 0.8rem; color: var(--accent);">🖥️ ' + preview.preview.browserStateCount + ' saved settings (theme, weather, clock, widgets, etc.)</div>';
|
||
}
|
||
|
||
previewContent.innerHTML = html;
|
||
previewDiv.style.display = 'block';
|
||
} else {
|
||
resultDiv.innerHTML = '⚠️ Invalid backup file: ' + escapeHtml(preview.error);
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||
resultDiv.style.border = '1px solid #f39c12';
|
||
previewDiv.style.display = 'none';
|
||
}
|
||
} catch (e) {
|
||
resultDiv.innerHTML = '❌ Could not read file: ' + escapeHtml(e.message);
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
previewDiv.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// === RESTORE: Server + browser state ===
|
||
restoreBtn?.addEventListener('click', async function() {
|
||
if (!selectedBackup) return;
|
||
if (!confirm('This will overwrite your current configuration and browser preferences. Continue?')) return;
|
||
restoreBtn.disabled = true;
|
||
restoreBtn.innerHTML = '<span class="brand-spinner"></span> Restoring...';
|
||
try {
|
||
// Handle legacy format
|
||
if (isLegacyFormat(selectedBackup)) {
|
||
restoreLegacyFormat(selectedBackup);
|
||
resultDiv.innerHTML = '✅ Legacy backup restored — browser settings and services imported.';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||
resultDiv.style.display = 'block';
|
||
setTimeout(function() { location.reload(); }, 2000);
|
||
restoreBtn.disabled = false;
|
||
restoreBtn.innerHTML = '⚡ Restore Everything';
|
||
return;
|
||
}
|
||
|
||
// v1.1+ / v2.0 — restore server-side first
|
||
var reloadCaddy = document.getElementById('backup-reload-caddy')?.checked ?? true;
|
||
var response = await secureFetch('/api/v1/backup/restore', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ backup: selectedBackup, options: { reloadCaddy: reloadCaddy } })
|
||
});
|
||
var data = await response.json();
|
||
|
||
// Then restore browser state
|
||
var browserCount = 0;
|
||
if (selectedBackup.browserState) {
|
||
browserCount = restoreBrowserState(selectedBackup.browserState);
|
||
}
|
||
|
||
if (data.success) {
|
||
var msg = '✅ ' + data.message;
|
||
if (browserCount > 0) msg += '<br><small style="color: var(--muted);">' + browserCount + ' browser settings restored</small>';
|
||
if (data.results.caddyReloaded) msg += '<br><small style="color: var(--muted);">Caddy configuration reloaded</small>';
|
||
resultDiv.innerHTML = msg;
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||
setTimeout(function() { location.reload(); }, 2000);
|
||
} else {
|
||
resultDiv.innerHTML = '⚠️ ' + escapeHtml(data.message);
|
||
if (browserCount > 0) resultDiv.innerHTML += '<br><small style="color: var(--muted);">' + browserCount + ' browser settings were restored</small>';
|
||
if (data.results?.errors?.length > 0) {
|
||
resultDiv.innerHTML += '<br><small>' + data.results.errors.map(function(e) { return escapeHtml(e.file) + ': ' + escapeHtml(e.error); }).join(', ') + '</small>';
|
||
}
|
||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||
resultDiv.style.border = '1px solid #f39c12';
|
||
}
|
||
resultDiv.style.display = 'block';
|
||
} catch (e) {
|
||
resultDiv.innerHTML = '❌ Restore failed: ' + escapeHtml(e.message);
|
||
resultDiv.style.display = 'block';
|
||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||
}
|
||
restoreBtn.disabled = false;
|
||
restoreBtn.innerHTML = '⚡ Restore Everything';
|
||
});
|
||
|
||
// === Schedules Tab (Premium) ===
|
||
async function loadSchedulesTab() {
|
||
if (!scheduleContainer) return;
|
||
scheduleContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||
try {
|
||
var res = await fetch('/api/v1/backups/schedule');
|
||
var data = await res.json();
|
||
|
||
if (data.premiumRequired) {
|
||
scheduleContainer.innerHTML = '<div class="panel-empty" style="padding: 24px; text-align: center;">' +
|
||
'<div style="font-size: 2rem; margin-bottom: 12px;">⭐</div>' +
|
||
'<div style="font-weight: 600; margin-bottom: 8px;">Premium Feature</div>' +
|
||
'<div style="font-size: 0.85rem; color: var(--muted);">Auto-backup scheduling requires a DashCaddy Premium subscription.</div>' +
|
||
'<button onclick="showNotification(\'Upgrade to Premium to enable auto-backups!\', \'info\'); scrollToSection(\'license\');" style="margin-top: 16px; padding: 8px 20px; background: linear-gradient(135deg, #f39c12, #e67e22); border: none; color: white; border-radius: 8px; cursor: pointer; font-weight: 600;">Upgrade to Premium</button>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
if (!data.success) throw new Error(data.error || 'Failed to load schedules');
|
||
|
||
var schedules = data.schedules || [];
|
||
|
||
if (schedules.length === 0) {
|
||
scheduleContainer.innerHTML = '<div class="panel-empty">' +
|
||
'<span class="empty-icon">⏰</span>' +
|
||
'<div>No backup schedules configured</div>' +
|
||
'<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Select apps below to enable auto-backup</div>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
var html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||
for (var i = 0; i < schedules.length; i++) {
|
||
var sch = schedules[i];
|
||
var nextRunStr = sch.nextRun ? new Date(sch.nextRun).toLocaleString() : 'Not scheduled';
|
||
var lastRunStr = sch.lastRun ? new Date(sch.lastRun).toLocaleString() : 'Never';
|
||
|
||
html += '<div style="padding: 12px 14px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
|
||
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">' +
|
||
' <div style="font-weight: 600; font-size: 0.9rem;">' + escapeHtml(sch.appId) + '</div>' +
|
||
' <label class="toggle-switch" style="display: flex; align-items: center; gap: 6px;">' +
|
||
' <input type="checkbox" class="schedule-toggle" data-appid="' + escapeHtml(sch.appId) + '"' + (sch.enabled ? ' checked' : '') + ' />' +
|
||
' <span style="font-size: 0.75rem; color: var(--muted);">' + (sch.enabled ? 'ON' : 'OFF') + '</span>' +
|
||
' </label>' +
|
||
'</div>' +
|
||
'<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.8rem; margin-bottom: 10px;">' +
|
||
' <div><span style="color: var(--muted);">Schedule:</span> <select class="schedule-select" data-appid="' + escapeHtml(sch.appId) + '" style="background: var(--base); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px;">' +
|
||
' <option value="hourly"' + (sch.schedule === 'hourly' ? ' selected' : '') + '>Hourly</option>' +
|
||
' <option value="daily"' + (sch.schedule === 'daily' ? ' selected' : '') + '>Daily</option>' +
|
||
' <option value="weekly"' + (sch.schedule === 'weekly' ? ' selected' : '') + '>Weekly</option>' +
|
||
' <option value="monthly"' + (sch.schedule === 'monthly' ? ' selected' : '') + '>Monthly</option>' +
|
||
' </select></div>' +
|
||
' <div><span style="color: var(--muted);">Keep last:</span> <input type="number" class="retention-input" data-appid="' + escapeHtml(sch.appId) + '" value="' + (sch.retention?.keep || 7) + '" min="1" max="100" style="width: 50px; background: var(--base); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px;" /></div>' +
|
||
'</div>' +
|
||
'<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 10px;">' +
|
||
' <div>Next run: ' + escapeHtml(nextRunStr) + '</div>' +
|
||
' <div>Last run: ' + escapeHtml(lastRunStr) + '</div>' +
|
||
'</div>' +
|
||
'<div style="display: flex; gap: 6px;">' +
|
||
' <button class="schedule-run-now" data-appid="' + escapeHtml(sch.appId) + '" style="padding: 5px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--ok-fg) 20%, transparent); border: 1px solid var(--ok-fg); color: var(--ok-fg); border-radius: 6px; cursor: pointer;">▶️ Run Now</button>' +
|
||
' <button class="schedule-delete" data-appid="' + escapeHtml(sch.appId) + '" style="padding: 5px 12px; font-size: 0.8rem; background: transparent; border: 1px solid var(--bad-fg); color: var(--bad-fg); border-radius: 6px; cursor: pointer;">🗑️ Remove</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Add "Add Schedule" section at bottom
|
||
html += '<div style="margin-top: 16px; padding: 16px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 10px; border: 1px dashed var(--border);">' +
|
||
'<h4 style="margin: 0 0 12px; font-size: 0.85rem; color: var(--muted);">➕ Add New Schedule</h4>' +
|
||
'<div style="display: flex; gap: 8px; flex-wrap: wrap;">' +
|
||
' <input type="text" id="new-schedule-appid" placeholder="App ID (e.g., plex, sonarr)" style="flex: 1; min-width: 150px; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);" />' +
|
||
' <select id="new-schedule-interval" style="padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);">' +
|
||
' <option value="hourly">Hourly</option>' +
|
||
' <option value="daily" selected>Daily</option>' +
|
||
' <option value="weekly">Weekly</option>' +
|
||
' <option value="monthly">Monthly</option>' +
|
||
' <option value="6h">Every 6 hours</option>' +
|
||
' <option value="30m">Every 30 minutes</option>' +
|
||
' </select>' +
|
||
' <input type="number" id="new-schedule-retention" value="7" min="1" max="100" placeholder="Keep" style="width: 70px; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);" />' +
|
||
' <button id="add-schedule-btn" style="padding: 8px 16px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Add Schedule</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
scheduleContainer.innerHTML = html;
|
||
|
||
// Wire up event listeners
|
||
scheduleContainer.querySelectorAll('.schedule-toggle').forEach(function(toggle) {
|
||
toggle.addEventListener('change', function() { updateSchedule(toggle.dataset.appid, { enabled: toggle.checked }); });
|
||
});
|
||
scheduleContainer.querySelectorAll('.schedule-select').forEach(function(sel) {
|
||
sel.addEventListener('change', function() { updateSchedule(sel.dataset.appid, { schedule: sel.value }); });
|
||
});
|
||
scheduleContainer.querySelectorAll('.retention-input').forEach(function(inp) {
|
||
inp.addEventListener('change', function() { updateSchedule(inp.dataset.appid, { retention: { keep: parseInt(inp.value) || 7 } }); });
|
||
});
|
||
scheduleContainer.querySelectorAll('.schedule-run-now').forEach(function(btn) {
|
||
btn.addEventListener('click', function() { runBackupNow(btn.dataset.appid); });
|
||
});
|
||
scheduleContainer.querySelectorAll('.schedule-delete').forEach(function(btn) {
|
||
btn.addEventListener('click', function() { deleteSchedule(btn.dataset.appid); });
|
||
});
|
||
document.getElementById('add-schedule-btn')?.addEventListener('click', addNewSchedule);
|
||
|
||
} catch (e) {
|
||
scheduleContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed to load: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function updateSchedule(appId, updates) {
|
||
try {
|
||
var res = await secureFetch('/api/v1/backups/schedule', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ appId, ...updates })
|
||
});
|
||
var data = await res.json();
|
||
if (data.success) {
|
||
showNotification('Schedule updated for ' + appId, 'success');
|
||
} else {
|
||
showNotification('Update failed: ' + (data.error || 'Unknown'), 'error');
|
||
loadSchedulesTab(); // Refresh on failure
|
||
}
|
||
} catch (e) {
|
||
showNotification('Error: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function runBackupNow(appId) {
|
||
try {
|
||
var res = await secureFetch('/api/v1/backups/backup/' + encodeURIComponent(appId), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
var data = await res.json();
|
||
if (data.success) {
|
||
showNotification('Backup started for ' + appId + '!', 'success');
|
||
} else {
|
||
showNotification('Backup failed: ' + (data.error || 'Unknown'), 'error');
|
||
}
|
||
} catch (e) {
|
||
showNotification('Error: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteSchedule(appId) {
|
||
if (!confirm('Remove backup schedule for ' + appId + '?')) return;
|
||
try {
|
||
var res = await secureFetch('/api/v1/backups/schedule/' + encodeURIComponent(appId), { method: 'DELETE' });
|
||
var data = await res.json();
|
||
if (data.success) {
|
||
showNotification('Schedule removed for ' + appId, 'success');
|
||
loadSchedulesTab();
|
||
} else {
|
||
showNotification('Delete failed: ' + (data.error || 'Unknown'), 'error');
|
||
}
|
||
} catch (e) {
|
||
showNotification('Error: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function addNewSchedule() {
|
||
var appId = document.getElementById('new-schedule-appid')?.value?.trim();
|
||
var interval = document.getElementById('new-schedule-interval')?.value || 'daily';
|
||
var retention = parseInt(document.getElementById('new-schedule-retention')?.value) || 7;
|
||
|
||
if (!appId) {
|
||
showNotification('Please enter an App ID', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
var res = await secureFetch('/api/v1/backups/schedule', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ appId, schedule: interval, retention: { keep: retention }, enabled: true })
|
||
});
|
||
var data = await res.json();
|
||
if (data.success) {
|
||
showNotification('Schedule created for ' + appId, 'success');
|
||
loadSchedulesTab();
|
||
// Clear inputs
|
||
var appIdInput = document.getElementById('new-schedule-appid');
|
||
if (appIdInput) appIdInput.value = '';
|
||
} else {
|
||
showNotification('Failed: ' + (data.error || 'Unknown'), 'error');
|
||
}
|
||
} catch (e) {
|
||
showNotification('Error: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// === Backups on Disk Tab ===
|
||
async function loadDiskBackups() {
|
||
if (!diskContainer) return;
|
||
diskContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||
try {
|
||
var res = await fetch('/api/v1/backups/files');
|
||
var data = await res.json();
|
||
if (!data.success) throw new Error(data.error || 'Failed to load');
|
||
|
||
var files = data.files || [];
|
||
|
||
if (files.length === 0) {
|
||
diskContainer.innerHTML = '<div class="panel-empty">' +
|
||
'<span class="empty-icon">💾</span>' +
|
||
'<div>No backup files on disk</div>' +
|
||
'<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Run a backup to create backup files</div>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
// Group by appId
|
||
var byApp = {};
|
||
for (var i = 0; i < files.length; i++) {
|
||
var f = files[i];
|
||
var appId = f.appId || 'unknown';
|
||
if (!byApp[appId]) byApp[appId] = [];
|
||
byApp[appId].push(f);
|
||
}
|
||
|
||
var html = '<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 12px;">' + files.length + ' backup file(s) across ' + Object.keys(byApp).length + ' app(s)</div>';
|
||
html += '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||
|
||
var appIds = Object.keys(byApp).sort();
|
||
for (var a = 0; a < appIds.length; a++) {
|
||
var appId = appIds[a];
|
||
var appFiles = byApp[appId];
|
||
html += '<div style="background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); overflow: hidden;">' +
|
||
'<div style="padding: 8px 12px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-bottom: 1px solid var(--border); font-weight: 600; font-size: 0.85rem;">' +
|
||
escapeHtml(appId) + ' <span style="font-weight: normal; color: var(--muted); font-size: 0.75rem;">(' + appFiles.length + ' backup(s))</span>' +
|
||
'</div>';
|
||
|
||
for (var j = 0; j < appFiles.length; j++) {
|
||
var f = appFiles[j];
|
||
var dateStr = new Date(f.timestamp).toLocaleString();
|
||
html += '<div style="padding: 10px 12px; border-bottom: 1px solid var(--border);">' +
|
||
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">' +
|
||
' <div style="font-weight: 500; font-size: 0.85rem;">' + escapeHtml(f.name) + '</div>' +
|
||
' <div style="font-size: 0.75rem; color: var(--muted);">' + f.sizeFormatted + '</div>' +
|
||
'</div>' +
|
||
'<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">' + dateStr + '</div>' +
|
||
'<div style="display: flex; gap: 6px;">' +
|
||
' <button class="disk-compare-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 3px 8px; font-size: 0.75rem; background: transparent; border: 1px solid var(--accent); color: var(--accent); border-radius: 5px; cursor: pointer;">Compare</button>' +
|
||
' <button class="disk-restore-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 3px 8px; font-size: 0.75rem; background: linear-gradient(135deg, var(--ok-fg), #27ae60); border: none; color: white; border-radius: 5px; cursor: pointer; font-weight: 500;">Restore</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
|
||
diskContainer.innerHTML = html;
|
||
|
||
// Wire up buttons
|
||
diskContainer.querySelectorAll('.disk-compare-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function() { compareBackupFile(btn.dataset.appid, btn.dataset.filename); });
|
||
});
|
||
diskContainer.querySelectorAll('.disk-restore-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function() { restoreBackupFile(btn.dataset.appid, btn.dataset.filename); });
|
||
});
|
||
} catch (e) {
|
||
diskContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
// === Backup History Tab ===
|
||
async function loadBackupHistory() {
|
||
if (!historyContainer) return;
|
||
historyContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||
try {
|
||
var res = await fetch('/api/v1/backups/history?limit=50');
|
||
var data = await res.json();
|
||
if (!data.success || !data.history?.length) {
|
||
historyContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📋</span> No backup history yet</div>';
|
||
return;
|
||
}
|
||
var html = '<div style="display: flex; flex-direction: column; gap: 6px;">';
|
||
for (var i = 0; i < data.history.length; i++) {
|
||
var bk = data.history[i];
|
||
var sizeMB = bk.size ? (bk.size / 1024 / 1024).toFixed(2) : '?';
|
||
html += '<div style="padding: 10px 12px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem;">';
|
||
html += ' <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">';
|
||
html += ' <span style="font-weight: 500;">' + escapeHtml(bk.name || 'backup') + '</span>';
|
||
html += ' <div style="display: flex; align-items: center; gap: 8px;">';
|
||
html += ' <span class="status-badge ' + (bk.status === 'success' ? 'success' : 'down') + '">' + escapeHtml(bk.status) + '</span>';
|
||
if (bk.status === 'success') html += ' <button class="backup-restore-btn" data-backup-id="' + escapeHtml(bk.id) + '" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>';
|
||
html += ' </div>';
|
||
html += ' </div>';
|
||
html += ' <div style="font-size: 0.75rem; color: var(--muted);">';
|
||
html += ' ' + new Date(bk.timestamp).toLocaleString() + ' | ' + sizeMB + ' MB | ' + (bk.duration ? (bk.duration / 1000).toFixed(1) + 's' : '--');
|
||
if (bk.encrypted) html += ' | 🔒';
|
||
html += ' </div>';
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
historyContainer.innerHTML = html;
|
||
historyContainer.querySelectorAll('.backup-restore-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function() { window.__restoreServerBackup(btn.dataset.backupId); });
|
||
});
|
||
} catch (e) {
|
||
historyContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
window.__restoreServerBackup = async function(backupId) {
|
||
if (!confirm('Restore from this server backup? This will overwrite current configuration.')) return;
|
||
try {
|
||
var res = await secureFetch('/api/v1/backups/restore/' + backupId, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ restoreServices: true, restoreConfig: true })
|
||
});
|
||
var data = await res.json();
|
||
if (data.success) {
|
||
showNotification('Restore completed successfully!', 'success');
|
||
location.reload();
|
||
} else {
|
||
showNotification('Restore failed: ' + (data.error || 'Unknown error'), 'error');
|
||
}
|
||
} catch (e) { showNotification('Restore error: ' + e.message, 'error'); }
|
||
};
|
||
|
||
// Lazy-load tabs
|
||
document.querySelector('[data-panel="backup-schedules-tab"]')?.addEventListener('click', loadSchedulesTab);
|
||
document.querySelector('[data-panel="backup-disk-tab"]')?.addEventListener('click', loadDiskBackups);
|
||
document.querySelector('[data-panel="backup-pointintime-tab"]')?.addEventListener('click', loadPointInTimeTab);
|
||
document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener('click', loadBackupHistory);
|
||
|
||
// ===== Point-in-Time Restore Tab =====
|
||
async function loadPointInTimeTab() {
|
||
if (!pointintimeContainer) return;
|
||
|
||
// Check premium
|
||
try {
|
||
var licenseRes = await fetch('/api/v1/license/status');
|
||
var licenseData = await licenseRes.json();
|
||
if (licenseData.tier !== 'premium') {
|
||
pointintimeContainer.innerHTML = '<div class="panel-empty" style="padding: 24px; text-align: center;">' +
|
||
'<div style="font-size: 2rem; margin-bottom: 12px;">⭐</div>' +
|
||
'<div style="font-weight: 600; margin-bottom: 8px;">Premium Feature</div>' +
|
||
'<div style="font-size: 0.85rem; color: var(--muted);">Point-in-time restore requires DashCaddy Premium with auto-backup enabled.</div>' +
|
||
'<button onclick="showNotification(\'Upgrade to Premium for point-in-time restore!\', \'info\'); scrollToSection(\'license\');" style="margin-top: 16px; padding: 8px 20px; background: linear-gradient(135deg, #f39c12, #e67e22); border: none; color: white; border-radius: 8px; cursor: pointer; font-weight: 600;">Upgrade to Premium</button>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
|
||
pointintimeContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||
|
||
try {
|
||
// Fetch services for dropdown
|
||
var servicesRes = await fetch('/api/v1/services');
|
||
var servicesData = await servicesRes.json();
|
||
var services = servicesData.services || [];
|
||
|
||
if (services.length === 0) {
|
||
pointintimeContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📦</span> No apps deployed yet</div>';
|
||
return;
|
||
}
|
||
|
||
var html = '<div style="padding: 8px 0 12px;">' +
|
||
'<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px;">' +
|
||
' <label style="font-size: 0.85rem; color: var(--muted); white-space: nowrap;">App:</label>' +
|
||
' <select id="pit-app-select" style="flex: 1; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base); max-width: 240px;">';
|
||
for (var i = 0; i < services.length; i++) {
|
||
html += '<option value="' + escapeHtml(services[i].id || services[i].name || '') + '">' +
|
||
escapeHtml(services[i].name || services[i].id || '') + '</option>';
|
||
}
|
||
html += '</select>' +
|
||
' <button id="pit-load-btn" style="padding: 8px 16px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Load Backups</button>' +
|
||
'</div>' +
|
||
'<div id="pit-backups-list" style="max-height: 320px; overflow-y: auto;"></div>' +
|
||
'</div>';
|
||
|
||
pointintimeContainer.innerHTML = html;
|
||
|
||
document.getElementById('pit-load-btn')?.addEventListener('click', function() {
|
||
var appId = document.getElementById('pit-app-select')?.value;
|
||
if (appId) loadPointInTimeBackups(appId);
|
||
});
|
||
} catch (e) {
|
||
pointintimeContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function loadPointInTimeBackups(appId) {
|
||
var listEl = document.getElementById('pit-backups-list');
|
||
if (!listEl) return;
|
||
|
||
listEl.innerHTML = '<div style="padding: 20px; text-align: center;"><span class="brand-spinner"></span> Loading backups...</div>';
|
||
|
||
try {
|
||
var res = await fetch('/api/v1/backups/files/' + encodeURIComponent(appId));
|
||
var data = await res.json();
|
||
|
||
if (!data.success || !data.files || data.files.length === 0) {
|
||
listEl.innerHTML = '<div class="panel-empty"><span class="empty-icon">💾</span> No backup files for ' + escapeHtml(appId) + '</div>';
|
||
return;
|
||
}
|
||
|
||
var html = '<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">' + data.files.length + ' backup(s)</div>' +
|
||
'<div style="display: flex; flex-direction: column; gap: 6px;">';
|
||
|
||
for (var i = 0; i < data.files.length; i++) {
|
||
var f = data.files[i];
|
||
var dateStr = new Date(f.timestamp).toLocaleString();
|
||
html += '<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
|
||
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">' +
|
||
' <div style="font-weight: 500; font-size: 0.85rem;">' + escapeHtml(f.name) + '</div>' +
|
||
' <div style="font-size: 0.75rem; color: var(--muted);">' + f.sizeFormatted + '</div>' +
|
||
'</div>' +
|
||
'<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">' + dateStr + '</div>' +
|
||
'<div style="display: flex; gap: 6px;">' +
|
||
' <button class="pit-compare-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 4px 10px; font-size: 0.75rem; background: transparent; border: 1px solid var(--accent); color: var(--accent); border-radius: 5px; cursor: pointer;">Compare</button>' +
|
||
' <button class="pit-restore-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 4px 10px; font-size: 0.75rem; background: linear-gradient(135deg, var(--ok-fg), #27ae60); border: none; color: white; border-radius: 5px; cursor: pointer; font-weight: 500;">Restore</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
html += '</div>';
|
||
listEl.innerHTML = html;
|
||
|
||
// Wire up buttons
|
||
listEl.querySelectorAll('.pit-compare-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function() { compareBackupFile(btn.dataset.appid, btn.dataset.filename); });
|
||
});
|
||
listEl.querySelectorAll('.pit-restore-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function() { restoreBackupFile(btn.dataset.appid, btn.dataset.filename); });
|
||
});
|
||
} catch (e) {
|
||
listEl.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function compareBackupFile(appId, filename) {
|
||
try {
|
||
var res = await secureFetch('/api/v1/backups/compare/' + encodeURIComponent(filename), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
});
|
||
var data = await res.json();
|
||
if (!data.success) {
|
||
showNotification('Compare failed: ' + (data.error || 'Unknown'), 'error');
|
||
return;
|
||
}
|
||
|
||
var diff = data.diff;
|
||
var html = '<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;" id="compare-overlay">' +
|
||
'<div style="background: var(--base); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; padding: 20px; border: 1px solid var(--border);">' +
|
||
'<h4 style="margin: 0 0 16px;">📊 Compare: ' + escapeHtml(filename) + '</h4>' +
|
||
'<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 16px;">Size: ' + (diff.sizeFormatted || '?') + ' | Created: ' + new Date(diff.timestamp).toLocaleString() + '</div>';
|
||
|
||
if (diff.services) {
|
||
var svcChanged = diff.services.hasChanges ? '🔴' : '🟢';
|
||
html += '<div style="margin-bottom: 12px; padding: 10px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border);">' +
|
||
'<div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 4px;">' + svcChanged + ' Services (backup vs current)</div>' +
|
||
'<div style="font-size: 0.8rem; color: var(--muted);">Backup: ' + diff.services.backupCount + ' services | Current: ' + diff.services.currentCount + ' services</div>';
|
||
if (diff.services.hasChanges) {
|
||
html += '<div style="margin-top: 6px; font-size: 0.8rem; color: #f39c12;">Services differ — restoring will replace current configuration</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
if (diff.config) {
|
||
var cfgChanged = diff.config.hasChanges ? '🔴' : '🟢';
|
||
html += '<div style="margin-bottom: 12px; padding: 10px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border);">' +
|
||
'<div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 4px;">' + cfgChanged + ' Configuration</div>';
|
||
if (diff.config.hasChanges) {
|
||
html += '<div style="font-size: 0.8rem; color: #f39c12;">Configuration differs — restoring will replace current settings</div>';
|
||
} else {
|
||
html += '<div style="font-size: 0.8rem; color: var(--ok-fg);">No changes</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '<button id="compare-close-btn" style="padding: 8px 20px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Close</button>' +
|
||
'</div></div>';
|
||
|
||
document.body.insertAdjacentHTML('beforeend', html);
|
||
document.getElementById('compare-close-btn')?.addEventListener('click', function() {
|
||
document.getElementById('compare-overlay')?.remove();
|
||
});
|
||
document.getElementById('compare-overlay')?.addEventListener('click', function(e) {
|
||
if (e.target === this) this.remove();
|
||
});
|
||
} catch (e) {
|
||
showNotification('Compare error: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function restoreBackupFile(appId, filename) {
|
||
if (!confirm('Restore ' + filename + ' for ' + appId + '?\n\nThis will replace current configuration, credentials, and data. Containers will be restarted.')) return;
|
||
|
||
try {
|
||
var res = await secureFetch('/api/v1/apps/' + encodeURIComponent(appId) + '/revert/' + encodeURIComponent(filename), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ restartContainers: true })
|
||
});
|
||
var data = await res.json();
|
||
if (data.success) {
|
||
showNotification(appId + ' restored to ' + filename, 'success');
|
||
setTimeout(function() { location.reload(); }, 1500);
|
||
} else {
|
||
showNotification('Restore failed: ' + (data.error || 'Unknown'), 'error');
|
||
}
|
||
} catch (e) {
|
||
showNotification('Restore error: ' + e.message, 'error');
|
||
}
|
||
}
|
||
})();
|