- Added PostgreSQL client installation to Dockerfile for database interactions. - Updated BackupScheduler to manage both backup jobs and email fetching jobs. - Implemented email fetching job with logging and error handling. - Enhanced the frontend to display scheduled jobs, including email fetch status. - Introduced email upload functionality with drag-and-drop support and progress tracking. - Added import_method tracking to email_messages for better source identification. - Updated email parsing logic for .eml and .msg files, including attachment handling. - Removed obsolete email scheduler service as functionality is integrated into BackupScheduler. - Updated requirements for extract-msg to the latest version. - Created migration script to add import_method column to email_messages table.
1011 lines
44 KiB
HTML
1011 lines
44 KiB
HTML
<!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">
|
|
<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>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div id="scheduler-status">
|
|
<div class="text-center p-4">
|
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
|
<span class="ms-2">Loading...</span>
|
|
</div>
|
|
</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 = `
|
|
<div class="alert alert-warning mb-0 m-3">
|
|
<i class="bi bi-exclamation-triangle"></i> Scheduler not running
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// 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>
|
|
`;
|
|
|
|
// 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;
|
|
} catch (error) {
|
|
console.error('Load scheduler status error:', error);
|
|
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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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'
|
|
});
|
|
}
|
|
|
|
// Create manual backup
|
|
async function createBackup(event) {
|
|
event.preventDefault();
|
|
|
|
const resultDiv = document.getElementById('backup-result');
|
|
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();
|
|
|
|
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:
|
|
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>`;
|
|
}
|
|
*/
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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...';
|
|
|
|
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();
|
|
|
|
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();
|
|
}
|
|
} else {
|
|
alert('❌ Restore failed: ' + (result.detail || result.message || 'Unknown error'));
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.innerHTML = 'Restore';
|
|
}
|
|
} catch (error) {
|
|
alert('❌ Restore error: ' + error.message);
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.innerHTML = 'Restore';
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Upload to offsite
|
|
async function uploadOffsite(jobId) {
|
|
if (!confirm('☁️ Upload this backup to offsite SFTP storage?\n\nTarget: sftp.acdu.dk:9022/backups')) return;
|
|
|
|
// 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...';
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/backups/offsite/${jobId}`, {method: 'POST'});
|
|
const result = await response.json();
|
|
|
|
// Reset button
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
|
|
if (response.ok) {
|
|
alert('✅ ' + result.message);
|
|
loadBackups();
|
|
} else {
|
|
alert('❌ Upload failed: ' + result.detail);
|
|
}
|
|
} catch (error) {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
alert('❌ Upload error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Delete backup
|
|
async function deleteBackup(jobId) {
|
|
alert('⚠️ Delete backup API er ikke implementeret endnu');
|
|
return;
|
|
|
|
/* Disabled until API implemented:
|
|
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);
|
|
}
|
|
*/
|
|
}
|
|
|
|
// Acknowledge notification
|
|
async function acknowledgeNotification(notificationId) {
|
|
console.warn('⚠️ Notification API ikke implementeret');
|
|
return;
|
|
|
|
/* Disabled until API implemented:
|
|
try {
|
|
await fetch(`/api/v1/backups/notifications/${notificationId}/acknowledge`, {method: 'POST'});
|
|
loadNotifications();
|
|
} catch (error) {
|
|
console.error('Acknowledge error:', error);
|
|
}
|
|
*/
|
|
}
|
|
|
|
// 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>
|