2025-12-15 12:28:12 +01:00
<!DOCTYPE html>
< html lang = "da" data-bs-theme = "light" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Backup System - BMC Hub< / title >
< link href = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel = "stylesheet" >
< link rel = "stylesheet" href = "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" >
< style >
:root {
--primary-color: #0f4c75;
--secondary-color: #3282b8;
--accent-color: #bbe1fa;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--light-bg: #f8f9fa;
--dark-bg: #1b2838;
}
[data-bs-theme="dark"] {
--light-bg: #1b2838;
--primary-color: #3282b8;
}
body {
background-color: var(--light-bg);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.navbar {
background-color: var(--primary-color) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.card-header {
background-color: var(--primary-color);
color: white;
border-radius: 10px 10px 0 0 !important;
padding: 1rem 1.25rem;
font-weight: 600;
}
.stat-card {
text-align: center;
padding: 1.5rem;
}
.stat-card .stat-value {
font-size: 2.5rem;
font-weight: bold;
color: var(--primary-color);
}
.stat-card .stat-label {
font-size: 0.9rem;
color: #6c757d;
text-transform: uppercase;
}
.badge-type {
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
}
.progress {
height: 25px;
font-size: 0.9rem;
}
.btn-action {
margin: 0.25rem;
}
.offsite-badge {
font-size: 0.75rem;
}
.notification-item {
border-left: 4px solid;
padding-left: 1rem;
margin-bottom: 0.75rem;
}
.notification-item.backup_failed {
border-color: var(--danger-color);
}
.notification-item.storage_low {
border-color: var(--warning-color);
}
.notification-item.backup_success {
border-color: var(--success-color);
}
.theme-toggle {
cursor: pointer;
font-size: 1.25rem;
color: white;
}
< / style >
< / head >
< body >
<!-- Navbar -->
< nav class = "navbar navbar-expand-lg navbar-dark" >
< div class = "container-fluid" >
< a class = "navbar-brand" href = "/" >
< i class = "bi bi-hdd-network" > < / i > BMC Hub - Backup System
< / a >
< div class = "d-flex align-items-center" >
< span class = "theme-toggle me-3" onclick = "toggleTheme()" >
< i class = "bi bi-moon-stars" id = "theme-icon" > < / i >
< / span >
< a href = "/api/docs" class = "btn btn-outline-light btn-sm" >
< i class = "bi bi-code-square" > < / i > API Docs
< / a >
< / div >
< / div >
< / nav >
< div class = "container-fluid py-4" >
<!-- Stats Row -->
< div class = "row mb-4" >
< div class = "col-md-3" >
< div class = "card stat-card" >
< div class = "stat-value" id = "total-backups" > -< / div >
< div class = "stat-label" > Total Backups< / div >
< / div >
< / div >
< div class = "col-md-3" >
< div class = "card stat-card" >
< div class = "stat-value text-success" id = "completed-backups" > -< / div >
< div class = "stat-label" > Completed< / div >
< / div >
< / div >
< div class = "col-md-3" >
< div class = "card stat-card" >
< div class = "stat-value text-warning" id = "pending-offsite" > -< / div >
< div class = "stat-label" > Pending Offsite< / div >
< / div >
< / div >
< div class = "col-md-3" >
< div class = "card stat-card" >
< div class = "stat-value" id = "storage-usage" > -< / div >
< div class = "stat-label" > Storage Used< / div >
< / div >
< / div >
< / div >
<!-- Storage Usage -->
< div class = "row mb-4" >
< div class = "col-12" >
< div class = "card" >
< div class = "card-header" >
< i class = "bi bi-hdd" > < / i > Storage Usage
< / div >
< div class = "card-body" >
< div class = "progress" id = "storage-progress" >
< div class = "progress-bar" role = "progressbar" style = "width: 0%" id = "storage-bar" >
0%
< / div >
< / div >
< p class = "text-muted mt-2 mb-0" id = "storage-details" > Loading...< / p >
< / div >
< / div >
< / div >
< / div >
<!-- Actions and Scheduler Status -->
< div class = "row mb-4" >
< div class = "col-md-8" >
< div class = "card" >
< div class = "card-header" >
< i class = "bi bi-play-circle" > < / i > Manual Backup
< / div >
< div class = "card-body" >
< form id = "backup-form" >
< div class = "row g-3 align-items-end" >
< div class = "col-md-4" >
< label class = "form-label" > Backup Type< / label >
< select class = "form-select" id = "backup-type" >
< option value = "full" > Full (Database + Files)< / option >
< option value = "database" > Database Only< / option >
< option value = "files" > Files Only< / option >
< / select >
< / div >
< div class = "col-md-4" >
< div class = "form-check mt-4" >
< input class = "form-check-input" type = "checkbox" id = "is-monthly" >
< label class = "form-check-label" for = "is-monthly" >
Monthly Backup (SQL Format)
< / label >
< / div >
< / div >
< div class = "col-md-4" >
< button type = "submit" class = "btn btn-primary w-100" >
< i class = "bi bi-download" > < / i > Create Backup
< / button >
< / div >
< / div >
< / form >
< div id = "backup-result" class = "mt-3" > < / div >
< hr class = "my-4" >
<!-- Upload Backup Form -->
< h6 class = "mb-3" > < i class = "bi bi-cloud-upload" > < / i > Upload Backup< / h6 >
< form id = "upload-form" onsubmit = "uploadBackup(event)" >
< div class = "row g-2" >
< div class = "col-md-6" >
< label for = "backup-file" class = "form-label" > Backup File< / label >
< input type = "file" class = "form-control" id = "backup-file" required
accept=".dump,.sql,.sql.gz,.tar.gz,.tgz">
< / div >
< div class = "col-md-6" >
< label for = "upload-type" class = "form-label" > Backup Type< / label >
< select class = "form-select" id = "upload-type" required >
< option value = "database" > Database< / option >
< option value = "files" > Files< / option >
< / select >
< / div >
< div class = "col-md-6" >
< div class = "form-check mt-4" >
< input class = "form-check-input" type = "checkbox" id = "upload-monthly" >
< label class = "form-check-label" for = "upload-monthly" >
Monthly Backup
< / label >
< / div >
< / div >
< div class = "col-md-6" >
< button type = "submit" class = "btn btn-success w-100 mt-4" >
< i class = "bi bi-upload" > < / i > Upload Backup
< / button >
< / div >
< / div >
< / form >
< div id = "upload-result" class = "mt-3" > < / div >
< / div >
< / div >
< / div >
< div class = "col-md-4" >
< div class = "card" >
2026-01-06 15:11:28 +01:00
< div class = "card-header d-flex justify-content-between align-items-center" >
< span > < i class = "bi bi-clock-history" > < / i > Scheduled Jobs< / span >
< button class = "btn btn-light btn-sm" onclick = "loadSchedulerStatus()" >
< i class = "bi bi-arrow-clockwise" > < / i >
< / button >
2025-12-15 12:28:12 +01:00
< / div >
2026-01-06 15:11:28 +01:00
< div class = "card-body p-0" >
2025-12-15 12:28:12 +01:00
< div id = "scheduler-status" >
2026-01-06 15:11:28 +01:00
< div class = "text-center p-4" >
< div class = "spinner-border spinner-border-sm" role = "status" > < / div >
< span class = "ms-2" > Loading...< / span >
< / div >
2025-12-15 12:28:12 +01:00
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- Backup History -->
< div class = "row" >
< div class = "col-md-8" >
< div class = "card" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< span > < i class = "bi bi-clock-history" > < / i > Backup History< / span >
< button class = "btn btn-light btn-sm" onclick = "refreshBackups()" >
< i class = "bi bi-arrow-clockwise" > < / i > Refresh
< / button >
< / div >
< div class = "card-body" >
< div class = "table-responsive" >
< table class = "table table-hover" >
< thead >
< tr >
< th > ID< / th >
< th > Type< / th >
< th > Format< / th >
< th > Size< / th >
< th > Status< / th >
< th > Offsite< / th >
< th > Created< / th >
< th > Actions< / th >
< / tr >
< / thead >
< tbody id = "backups-table" >
< tr >
< td colspan = "8" class = "text-center" >
< div class = "spinner-border" role = "status" > < / div >
< p class = "mt-2" > Loading backups...< / p >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
< / div >
<!-- Notifications -->
< div class = "col-md-4" >
< div class = "card" >
< div class = "card-header" >
< i class = "bi bi-bell" > < / i > Recent Notifications
< / div >
< div class = "card-body" style = "max-height: 500px; overflow-y: auto;" >
< div id = "notifications-list" >
< div class = "text-center" >
< div class = "spinner-border spinner-border-sm" role = "status" > < / div >
< p class = "mt-2 text-muted" > Loading...< / p >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- Restore Confirmation Modal -->
< div class = "modal fade" id = "restoreModal" tabindex = "-1" >
< div class = "modal-dialog" >
< div class = "modal-content" >
< div class = "modal-header bg-warning" >
< h5 class = "modal-title" >
< i class = "bi bi-exclamation-triangle" > < / i > Bekræft Restore
< / h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< div class = "alert alert-danger" >
< strong > ADVARSEL:< / strong > Systemet vil blive lukket ned under restore-processen.
Alle aktive brugere vil miste forbindelsen.
< / div >
< p > Er du sikker på, at du vil gendanne fra denne backup?< / p >
< p class = "text-muted mb-0" >
< strong > Backup ID:< / strong > < span id = "restore-job-id" > < / span > < br >
< strong > Type:< / strong > < span id = "restore-job-type" > < / span > < br >
< strong > Estimeret tid:< / strong > 5-10 minutter
< / p >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Annuller< / button >
< button type = "button" class = "btn btn-danger" onclick = "confirmRestore()" >
< i class = "bi bi-arrow-counterclockwise" > < / i > Gendan Nu
< / button >
< / div >
< / div >
< / div >
< / div >
< script src = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" > < / script >
< script >
let selectedJobId = null;
let restoreModal = null;
// Initialize
document.addEventListener('DOMContentLoaded', function() {
restoreModal = new bootstrap.Modal(document.getElementById('restoreModal'));
loadDashboard();
// Refresh every 30 seconds
setInterval(loadDashboard, 30000);
// Setup backup form
document.getElementById('backup-form').addEventListener('submit', createBackup);
});
// Load all dashboard data
async function loadDashboard() {
await Promise.all([
loadBackups(),
loadStorageStats(),
loadNotifications(),
loadSchedulerStatus()
]);
}
// Load backups list
async function loadBackups() {
try {
const response = await fetch('/api/v1/backups/jobs?limit=50');
const backups = await response.json();
const tbody = document.getElementById('backups-table');
if (backups.length === 0) {
tbody.innerHTML = '< tr > < td colspan = "8" class = "text-center text-muted" > No backups found< / td > < / tr > ';
updateStats(backups);
return;
}
tbody.innerHTML = backups.map(b => `
< tr >
< td > < strong > #${b.id}< / strong > < / td >
< td >
< span class = "badge badge-type ${getTypeBadgeClass(b.job_type)}" >
${b.job_type} ${b.is_monthly ? '(Monthly)' : ''}
< / span >
< / td >
< td > < code > ${b.backup_format}< / code > < / td >
< td > ${formatBytes(b.file_size_bytes)}< / td >
< td > ${getStatusBadge(b.status)}< / td >
< td > ${getOffsiteBadge(b)}< / td >
< td > ${formatDate(b.created_at)}< / td >
< td >
${b.status === 'completed' ? `
< button class = "btn btn-sm btn-warning btn-action" onclick = "showRestore(${b.id}, '${b.job_type}')" title = "Restore" >
< i class = "bi bi-arrow-counterclockwise" > < / i >
< / button >
${b.offsite_uploaded_at === null ? `
< button class = "btn btn-sm btn-info btn-action" onclick = "uploadOffsite(${b.id})" title = "Upload Offsite" >
< i class = "bi bi-cloud-upload" > < / i >
< / button >
` : ''}
` : ''}
< button class = "btn btn-sm btn-danger btn-action" onclick = "deleteBackup(${b.id})" title = "Delete" >
< i class = "bi bi-trash" > < / i >
< / button >
< / td >
< / tr >
`).join('');
updateStats(backups);
} catch (error) {
console.error('Load backups error:', error);
document.getElementById('backups-table').innerHTML =
'< tr > < td colspan = "8" class = "text-center text-danger" > Failed to load backups< / td > < / tr > ';
}
}
// Load storage stats
async function loadStorageStats() {
try {
const response = await fetch('/api/v1/backups/storage');
const stats = await response.json();
const bar = document.getElementById('storage-bar');
const pct = Math.min(stats.usage_pct, 100);
bar.style.width = pct + '%';
bar.textContent = pct.toFixed(1) + '%';
if (stats.warning) {
bar.classList.remove('bg-success', 'bg-info');
bar.classList.add('bg-danger');
} else if (pct > 60) {
bar.classList.remove('bg-success', 'bg-danger');
bar.classList.add('bg-warning');
} else {
bar.classList.remove('bg-warning', 'bg-danger');
bar.classList.add('bg-success');
}
document.getElementById('storage-details').textContent =
`${stats.total_size_gb.toFixed(2)} GB used of ${stats.max_size_gb} GB (${stats.file_count} files)`;
document.getElementById('storage-usage').textContent = stats.total_size_gb.toFixed(1) + ' GB';
} catch (error) {
console.error('Load storage stats error:', error);
}
}
// Load notifications
async function loadNotifications() {
try {
const response = await fetch('/api/v1/backups/notifications?limit=10');
const notifications = await response.json();
const container = document.getElementById('notifications-list');
if (notifications.length === 0) {
container.innerHTML = '< p class = "text-muted text-center" > No notifications< / p > ';
return;
}
container.innerHTML = notifications.map(n => `
< div class = "notification-item ${n.event_type} ${n.acknowledged ? 'opacity-50' : ''}" >
< small class = "text-muted" > ${formatDate(n.sent_at)}< / small >
< p class = "mb-1" > ${n.message}< / p >
${!n.acknowledged ? `
< button class = "btn btn-sm btn-outline-secondary" onclick = "acknowledgeNotification(${n.id})" >
< i class = "bi bi-check" > < / i > Acknowledge
< / button >
` : '< small class = "text-success" > < i class = "bi bi-check-circle" > < / i > Acknowledged< / small > '}
< / div >
`).join('');
} catch (error) {
console.error('Load notifications error:', error);
}
}
// Load scheduler status
async function loadSchedulerStatus() {
try {
const response = await fetch('/api/v1/backups/scheduler/status');
const status = await response.json();
const container = document.getElementById('scheduler-status');
if (!status.running) {
container.innerHTML = `
2026-01-06 15:11:28 +01:00
< div class = "alert alert-warning mb-0 m-3" >
2025-12-15 12:28:12 +01:00
< i class = "bi bi-exclamation-triangle" > < / i > Scheduler not running
< / div >
`;
return;
}
2026-01-06 15:11:28 +01:00
// Group jobs by type
const backupJobs = status.jobs.filter(j => ['daily_backup', 'monthly_backup'].includes(j.id));
const maintenanceJobs = status.jobs.filter(j => ['backup_rotation', 'storage_check', 'offsite_upload', 'offsite_retry'].includes(j.id));
const emailJob = status.jobs.find(j => j.id === 'email_fetch');
let html = `
< div class = "list-group list-group-flush" >
< div class = "list-group-item bg-success bg-opacity-10" >
< div class = "d-flex align-items-center" >
< i class = "bi bi-check-circle-fill text-success me-2" > < / i >
< strong > Scheduler Active< / strong >
< / div >
< / div >
2025-12-15 12:28:12 +01:00
`;
2026-01-06 15:11:28 +01:00
// Email Fetch Job
if (emailJob) {
const nextRun = emailJob.next_run ? new Date(emailJob.next_run) : null;
const timeUntil = nextRun ? formatTimeUntil(nextRun) : 'N/A';
html += `
< div class = "list-group-item" >
< div class = "d-flex justify-content-between align-items-start" >
< div >
< i class = "bi bi-envelope text-primary" > < / i >
< strong class = "ms-1" > Email Fetch< / strong >
< br >
< small class = "text-muted" > Every 5 minutes< / small >
< / div >
< span class = "badge bg-primary" > ${timeUntil}< / span >
< / div >
< / div >
`;
}
// Backup Jobs
if (backupJobs.length > 0) {
html += `
< div class = "list-group-item bg-light" >
< small class = "text-muted fw-bold" > < i class = "bi bi-database" > < / i > BACKUP JOBS< / small >
< / div >
`;
backupJobs.forEach(job => {
const nextRun = job.next_run ? new Date(job.next_run) : null;
const timeUntil = nextRun ? formatTimeUntil(nextRun) : 'N/A';
const icon = job.id === 'daily_backup' ? 'bi-arrow-repeat' : 'bi-calendar-month';
html += `
< div class = "list-group-item" >
< div class = "d-flex justify-content-between align-items-start" >
< div >
< i class = "bi ${icon} text-info" > < / i >
< small class = "ms-1" > ${job.name}< / small >
< br >
< small class = "text-muted" > ${nextRun ? formatDateTime(nextRun) : 'N/A'}< / small >
< / div >
< span class = "badge bg-info" > ${timeUntil}< / span >
< / div >
< / div >
`;
});
}
// Maintenance Jobs
if (maintenanceJobs.length > 0) {
html += `
< div class = "list-group-item bg-light" >
< small class = "text-muted fw-bold" > < i class = "bi bi-wrench" > < / i > MAINTENANCE< / small >
< / div >
`;
maintenanceJobs.forEach(job => {
const nextRun = job.next_run ? new Date(job.next_run) : null;
const timeUntil = nextRun ? formatTimeUntil(nextRun) : 'N/A';
html += `
< div class = "list-group-item" >
< div class = "d-flex justify-content-between align-items-start" >
< div style = "max-width: 70%;" >
< i class = "bi bi-gear text-secondary" > < / i >
< small class = "ms-1" > ${job.name}< / small >
< br >
< small class = "text-muted" > ${nextRun ? formatDateTime(nextRun) : 'N/A'}< / small >
< / div >
< span class = "badge bg-secondary text-nowrap" > ${timeUntil}< / span >
< / div >
< / div >
`;
});
}
html += `< / div > `;
container.innerHTML = html;
2025-12-15 12:28:12 +01:00
} catch (error) {
console.error('Load scheduler status error:', error);
2026-01-06 15:11:28 +01:00
document.getElementById('scheduler-status').innerHTML = `
< div class = "alert alert-danger m-3" >
< i class = "bi bi-exclamation-triangle" > < / i > Failed to load scheduler status
< / div >
`;
2025-12-15 12:28:12 +01:00
}
}
2026-01-06 15:11:28 +01:00
function formatTimeUntil(date) {
const now = new Date();
const diff = date - now;
if (diff < 0 ) return ' Overdue ' ;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d`;
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return 'Now';
}
function formatDateTime(date) {
return date.toLocaleString('da-DK', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
2025-12-15 12:28:12 +01:00
// Create manual backup
async function createBackup(event) {
event.preventDefault();
2025-12-16 15:36:11 +01:00
const resultDiv = document.getElementById('backup-result');
2025-12-15 12:28:12 +01:00
const type = document.getElementById('backup-type').value;
const isMonthly = document.getElementById('is-monthly').checked;
resultDiv.innerHTML = '< div class = "alert alert-info" > < i class = "bi bi-hourglass-split" > < / i > Creating backup...< / div > ';
try {
const response = await fetch('/api/v1/backups', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({job_type: type, is_monthly: isMonthly})
});
const result = await response.json();
if (response.ok) {
resultDiv.innerHTML = `< div class = "alert alert-success" > ${result.message}< / div > `;
setTimeout(() => loadBackups(), 2000);
} else {
resultDiv.innerHTML = `< div class = "alert alert-danger" > Error: ${result.detail}< / div > `;
}
} catch (error) {
resultDiv.innerHTML = `< div class = "alert alert-danger" > Error: ${error.message}< / div > `;
}
}
// Upload backup
async function uploadBackup(event) {
event.preventDefault();
2025-12-16 15:36:11 +01:00
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = '< div class = "alert alert-warning" > < i class = "bi bi-exclamation-triangle me-2" > < / i > Backup upload API er ikke implementeret endnu< / div > ';
return;
/* Disabled until API implemented:
2025-12-15 12:28:12 +01:00
const fileInput = document.getElementById('backup-file');
const type = document.getElementById('upload-type').value;
const isMonthly = document.getElementById('upload-monthly').checked;
if (!fileInput.files || fileInput.files.length === 0) {
resultDiv.innerHTML = '< div class = "alert alert-danger" > Please select a file< / div > ';
return;
}
const file = fileInput.files[0];
const formData = new FormData();
formData.append('file', file);
resultDiv.innerHTML = `< div class = "alert alert-info" >
< i class = "bi bi-hourglass-split" > < / i > Uploading ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)...
< / div > `;
try {
const response = await fetch(`/api/v1/backups/upload?backup_type=${type}& is_monthly=${isMonthly}`, {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
resultDiv.innerHTML = `
< div class = "alert alert-success" >
< strong > ✅ Upload successful!< / strong > < br >
Job ID: ${result.job_id}< br >
Size: ${result.file_size_mb} MB< br >
Checksum: ${result.checksum.substring(0, 16)}...
< / div >
`;
fileInput.value = ''; // Clear file input
setTimeout(() => loadBackups(), 2000);
} else {
resultDiv.innerHTML = `< div class = "alert alert-danger" > Error: ${result.detail}< / div > `;
}
} catch (error) {
resultDiv.innerHTML = `< div class = "alert alert-danger" > Upload error: ${error.message}< / div > `;
}
2026-01-02 12:35:02 +01:00
*/
2025-12-15 12:28:12 +01:00
}
// Show restore modal
function showRestore(jobId, jobType) {
selectedJobId = jobId;
document.getElementById('restore-job-id').textContent = jobId;
document.getElementById('restore-job-type').textContent = jobType;
restoreModal.show();
}
// Confirm restore
async function confirmRestore() {
if (!selectedJobId) return;
2026-01-02 12:35:02 +01:00
// Show loading state
const modalBody = document.querySelector('#restoreModal .modal-body');
const confirmBtn = document.querySelector('#restoreModal .btn-danger');
confirmBtn.disabled = true;
confirmBtn.innerHTML = '< span class = "spinner-border spinner-border-sm me-2" > < / span > Restoring...';
2025-12-15 12:28:12 +01:00
try {
const response = await fetch(`/api/v1/backups/restore/${selectedJobId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({confirmation: true})
});
const result = await response.json();
2026-01-02 12:35:02 +01:00
if (response.ok & & result.success) {
// Hide modal
restoreModal.hide();
// Show success with new database instructions
if (result.new_database) {
showRestoreSuccess(result);
} else {
alert('✅ Restore completed successfully!');
window.location.reload();
}
2025-12-15 12:28:12 +01:00
} else {
2026-01-02 12:35:02 +01:00
alert('❌ Restore failed: ' + (result.detail || result.message || 'Unknown error'));
confirmBtn.disabled = false;
confirmBtn.innerHTML = 'Restore';
2025-12-15 12:28:12 +01:00
}
} catch (error) {
2026-01-02 12:35:02 +01:00
alert('❌ Restore error: ' + error.message);
confirmBtn.disabled = false;
confirmBtn.innerHTML = 'Restore';
2025-12-15 12:28:12 +01:00
}
}
2026-01-02 12:35:02 +01:00
function showRestoreSuccess(result) {
// Create modal with instructions
const instructionsHtml = `
< div class = "modal fade" id = "restoreSuccessModal" tabindex = "-1" data-bs-backdrop = "static" >
< div class = "modal-dialog modal-lg" >
< div class = "modal-content" >
< div class = "modal-header bg-success text-white" >
< h5 class = "modal-title" >
< i class = "bi bi-check-circle-fill me-2" > < / i >
Database Restored Successfully!
< / h5 >
< / div >
< div class = "modal-body" >
< div class = "alert alert-info" >
< i class = "bi bi-info-circle me-2" > < / i >
< strong > Safe Restore:< / strong > Database restored to NEW database:
< code > ${result.new_database}< / code >
< / div >
< h6 class = "mt-4 mb-3" > 📋 Next Steps:< / h6 >
< ol class = "list-group list-group-numbered" >
${result.instructions.map(instr => `
< li class = "list-group-item" >
< div class = "d-flex justify-content-between align-items-start" >
< div class = "ms-2 me-auto" >
${instr}
${instr.includes('DATABASE_URL') ? `
< button class = "btn btn-sm btn-outline-primary mt-2" onclick = "copyToClipboard('${result.instructions[0].split(': ')[1]}')" >
< i class = "bi bi-clipboard" > < / i > Copy DATABASE_URL
< / button >
` : ''}
< / div >
< / div >
< / li >
`).join('')}
< / ol >
< div class = "alert alert-warning mt-4" >
< i class = "bi bi-exclamation-triangle me-2" > < / i >
< strong > Important:< / strong > Test system thoroughly before completing cleanup!
< / div >
< div class = "mt-4" >
< h6 > 🔧 Cleanup Commands (after testing):< / h6 >
< pre class = "bg-dark text-light p-3 rounded" > < code > docker-compose stop api
echo 'DROP DATABASE bmc_hub;' | docker exec -i bmc-hub-postgres psql -U bmc_hub -d postgres
echo 'ALTER DATABASE ${result.new_database} RENAME TO bmc_hub;' | docker exec -i bmc-hub-postgres psql -U bmc_hub -d postgres
# Revert .env to use bmc_hub
docker-compose start api< / code > < / pre >
< / div >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-primary" onclick = "location.reload()" >
< i class = "bi bi-arrow-clockwise me-2" > < / i > Reload Page
< / button >
< / div >
< / div >
< / div >
< / div >
`;
// Append to body and show
document.body.insertAdjacentHTML('beforeend', instructionsHtml);
const successModal = new bootstrap.Modal(document.getElementById('restoreSuccessModal'));
successModal.show();
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
alert('✅ Copied to clipboard!');
}).catch(err => {
alert('❌ Failed to copy: ' + err);
});
}
2025-12-15 12:28:12 +01:00
// Upload to offsite
async function uploadOffsite(jobId) {
2026-01-02 12:35:02 +01:00
if (!confirm('☁️ Upload this backup to offsite SFTP storage?\n\nTarget: sftp.acdu.dk:9022/backups')) return;
2025-12-16 15:36:11 +01:00
2026-01-02 12:35:02 +01:00
// Show loading indicator
const btn = event.target.closest('button');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '< span class = "spinner-border spinner-border-sm me-2" > < / span > Uploading...';
2025-12-15 12:28:12 +01:00
try {
const response = await fetch(`/api/v1/backups/offsite/${jobId}`, {method: 'POST'});
const result = await response.json();
2026-01-02 12:35:02 +01:00
// Reset button
btn.disabled = false;
btn.innerHTML = originalHtml;
2025-12-15 12:28:12 +01:00
if (response.ok) {
2026-01-02 12:35:02 +01:00
alert('✅ ' + result.message);
2025-12-15 12:28:12 +01:00
loadBackups();
} else {
2026-01-02 12:35:02 +01:00
alert('❌ Upload failed: ' + result.detail);
2025-12-15 12:28:12 +01:00
}
} catch (error) {
2026-01-02 12:35:02 +01:00
btn.disabled = false;
btn.innerHTML = originalHtml;
alert('❌ Upload error: ' + error.message);
2025-12-15 12:28:12 +01:00
}
}
// Delete backup
async function deleteBackup(jobId) {
2025-12-16 15:36:11 +01:00
alert('⚠️ Delete backup API er ikke implementeret endnu');
return;
/* Disabled until API implemented:
2025-12-15 12:28:12 +01:00
if (!confirm('Delete this backup? This cannot be undone.')) return;
try {
const response = await fetch(`/api/v1/backups/jobs/${jobId}`, {method: 'DELETE'});
const result = await response.json();
if (response.ok) {
loadBackups();
} else {
alert('Delete failed: ' + result.detail);
}
} catch (error) {
alert('Delete error: ' + error.message);
}
2026-01-02 12:35:02 +01:00
*/
2025-12-15 12:28:12 +01:00
}
// Acknowledge notification
async function acknowledgeNotification(notificationId) {
2025-12-16 15:36:11 +01:00
console.warn('⚠️ Notification API ikke implementeret');
return;
/* Disabled until API implemented:
2025-12-15 12:28:12 +01:00
try {
await fetch(`/api/v1/backups/notifications/${notificationId}/acknowledge`, {method: 'POST'});
loadNotifications();
} catch (error) {
console.error('Acknowledge error:', error);
}
2026-01-02 12:35:02 +01:00
*/
2025-12-15 12:28:12 +01:00
}
// Refresh backups
function refreshBackups() {
loadBackups();
}
// Update stats
function updateStats(backups) {
document.getElementById('total-backups').textContent = backups.length;
document.getElementById('completed-backups').textContent =
backups.filter(b => b.status === 'completed').length;
document.getElementById('pending-offsite').textContent =
backups.filter(b => b.status === 'completed' & & !b.offsite_uploaded_at).length;
}
// Utility functions
function formatBytes(bytes) {
if (!bytes) return '-';
const mb = bytes / 1024 / 1024;
return mb > 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(2)} MB`;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('da-DK', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function getTypeBadgeClass(type) {
const classes = {
'database': 'bg-primary',
'files': 'bg-info',
'full': 'bg-success'
};
return classes[type] || 'bg-secondary';
}
function getStatusBadge(status) {
const badges = {
'pending': '< span class = "badge bg-secondary" > Pending< / span > ',
'running': '< span class = "badge bg-warning" > Running< / span > ',
'completed': '< span class = "badge bg-success" > Completed< / span > ',
'failed': '< span class = "badge bg-danger" > Failed< / span > '
};
return badges[status] || status;
}
function getOffsiteBadge(backup) {
if (backup.offsite_uploaded_at) {
return '< span class = "badge bg-success offsite-badge" > < i class = "bi bi-cloud-check" > < / i > Uploaded< / span > ';
} else if (backup.offsite_retry_count > 0) {
return `< span class = "badge bg-warning offsite-badge" > < i class = "bi bi-exclamation-triangle" > < / i > Retry ${backup.offsite_retry_count}< / span > `;
} else {
return '< span class = "badge bg-secondary offsite-badge" > Pending< / span > ';
}
}
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const icon = document.getElementById('theme-icon');
const currentTheme = html.getAttribute('data-bs-theme');
if (currentTheme === 'light') {
html.setAttribute('data-bs-theme', 'dark');
icon.classList.remove('bi-moon-stars');
icon.classList.add('bi-sun');
localStorage.setItem('theme', 'dark');
} else {
html.setAttribute('data-bs-theme', 'light');
icon.classList.remove('bi-sun');
icon.classList.add('bi-moon-stars');
localStorage.setItem('theme', 'light');
}
}
// Load saved theme
(function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', savedTheme);
if (savedTheme === 'dark') {
document.getElementById('theme-icon').classList.replace('bi-moon-stars', 'bi-sun');
}
})();
< / script >
< / body >
< / html >