Files
dashcaddy/status/js/backup-restore.js
T

922 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========== 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');
}
}
})();