feat: Business Central backup Docker container with web management panel

Python backup module (Azure AD OAuth2 → BC API v2.0 → GPG AES256 → S3),
Flask web UI on port 1890 with SSE live log streaming, dual incremental +
full backup schedules, chain restore merging full + incrementals, and
per-entity file browser with ZIP download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 20:09:32 +02:00
parent 025eb3896c
commit b651eca285
11 changed files with 2781 additions and 0 deletions

278
templates/base.html Normal file
View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BackupBC{% block title %}{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
:root { --sidebar-bg: #0f172a; --sidebar-width: 230px; }
body { background: #f1f5f9; }
.sidebar {
width: var(--sidebar-width);
min-height: 100vh;
background: var(--sidebar-bg);
position: fixed;
top: 0; left: 0;
display: flex;
flex-direction: column;
padding: 1.5rem 1rem;
z-index: 100;
}
.sidebar-brand {
color: #fff;
font-weight: 700;
font-size: 1.1rem;
text-decoration: none;
display: flex;
align-items: center;
gap: .5rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255,255,255,.1);
}
.sidebar-brand .brand-icon {
width: 32px; height: 32px;
background: #6366f1;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: .9rem;
}
.sidebar .nav-link {
color: rgba(255,255,255,.55);
padding: .5rem .75rem;
border-radius: 8px;
font-size: .875rem;
display: flex;
align-items: center;
gap: .6rem;
transition: all .15s;
}
.sidebar .nav-link:hover,
.sidebar .nav-link.active {
color: #fff;
background: rgba(255,255,255,.1);
}
.sidebar .nav-link.active { background: rgba(99,102,241,.25); color: #a5b4fc; }
.main-content {
margin-left: var(--sidebar-width);
min-height: 100vh;
padding: 2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h4 { font-weight: 700; margin: 0; }
.stat-card { border: none; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.stat-icon {
width: 44px; height: 44px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.badge-running { background: #f59e0b; color: #fff; }
.badge-success { background: #10b981; color: #fff; }
.badge-failed { background: #ef4444; color: #fff; }
.log-terminal {
background: #1e1e2e;
color: #cdd6f4;
font-family: 'Cascadia Code', 'Fira Code', 'Courier New', monospace;
font-size: .78rem;
line-height: 1.5;
padding: 1rem;
border-radius: 8px;
height: 420px;
overflow-y: auto;
}
.log-terminal .log-warn { color: #f9e2af; }
.log-terminal .log-error { color: #f38ba8; }
.log-terminal .log-ok { color: #a6e3a1; }
.log-terminal .log-info { color: #89dceb; }
.table thead th { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #64748b; font-weight: 600; }
.table td { vertical-align: middle; font-size: .875rem; }
#backupBtn { min-width: 160px; }
</style>
</head>
<body>
<nav class="sidebar">
<a href="{{ url_for('dashboard') }}" class="sidebar-brand">
<div class="brand-icon"><i class="bi bi-cloud-arrow-up-fill text-white"></i></div>
BackupBC
</a>
<ul class="nav flex-column gap-1">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'history' %}active{% endif %}" href="{{ url_for('history') }}">
<i class="bi bi-clock-history"></i> History
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint in ('restore_list','restore_view') %}active{% endif %}" href="{{ url_for('restore_list') }}">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'settings' %}active{% endif %}" href="{{ url_for('settings') }}">
<i class="bi bi-gear"></i> Settings
</a>
</li>
</ul>
<div class="mt-auto pt-3" style="border-top:1px solid rgba(255,255,255,.08); font-size:.7rem; color:rgba(255,255,255,.3)">
BackupBC v1.0 · Business Central
</div>
</nav>
<main class="main-content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show shadow-sm" role="alert">
<i class="bi bi-{% if category == 'success' %}check-circle{% else %}exclamation-triangle{% endif %} me-2"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<!-- Backup Modal -->
<div class="modal fade" id="backupModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">
<i class="bi bi-cloud-upload me-2 text-primary"></i>
<span id="modalTitle">Starting Backup…</span>
</h5>
</div>
<div class="modal-body">
<div id="modalStatus" class="d-flex align-items-center gap-2 mb-3">
<div class="spinner-border spinner-border-sm text-primary" id="modalSpinner" role="status"></div>
<span id="modalStatusText" class="text-muted small">Authenticating with Azure AD…</span>
</div>
<div class="log-terminal" id="logTerminal"></div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-secondary btn-sm" id="modalCloseBtn" data-bs-dismiss="modal" disabled>
Close
</button>
<a href="{{ url_for('history') }}" class="btn btn-primary btn-sm d-none" id="viewHistoryBtn">
<i class="bi bi-clock-history me-1"></i>View History
</a>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
let _backupModal = null;
function runBackupNow(forceFull) {
if (forceFull === undefined) forceFull = true;
const terminal = document.getElementById('logTerminal');
const statusText = document.getElementById('modalStatusText');
const spinner = document.getElementById('modalSpinner');
const closeBtn = document.getElementById('modalCloseBtn');
const histBtn = document.getElementById('viewHistoryBtn');
const title = document.getElementById('modalTitle');
terminal.innerHTML = '';
spinner.classList.remove('d-none');
spinner.className = 'spinner-border spinner-border-sm text-primary';
statusText.textContent = 'Authenticating with Azure AD…';
statusText.className = 'text-muted small';
closeBtn.disabled = true;
histBtn.classList.add('d-none');
title.textContent = forceFull ? 'Full Backup in Progress' : 'Incremental Backup in Progress';
if (!_backupModal) _backupModal = new bootstrap.Modal(document.getElementById('backupModal'));
_backupModal.show();
fetch('/api/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({force_full: forceFull}),
})
.then(r => r.json())
.then(data => {
if (data.error) {
statusText.textContent = 'Error: ' + data.error;
statusText.className = 'text-danger small fw-semibold';
spinner.className = 'd-none';
closeBtn.disabled = false;
return;
}
const runId = data.run_id;
statusText.textContent = 'Backup running (run #' + runId + ')…';
const evtSrc = new EventSource('/api/run/' + runId + '/stream');
evtSrc.onmessage = function(e) {
const msg = JSON.parse(e.data);
if (msg.keepalive) return;
if (msg.done) {
evtSrc.close();
spinner.className = 'd-none';
closeBtn.disabled = false;
histBtn.classList.remove('d-none');
if (msg.status === 'success') {
title.textContent = forceFull ? 'Full Backup Complete' : 'Incremental Backup Complete';
statusText.innerHTML = '<i class="bi bi-check-circle-fill text-success me-1"></i><span class="text-success fw-semibold">Backup completed successfully!</span>';
} else {
title.textContent = 'Backup Failed';
statusText.innerHTML = '<i class="bi bi-x-circle-fill text-danger me-1"></i><span class="text-danger fw-semibold">Backup failed — see log below.</span>';
}
return;
}
if (msg.log) appendLog(terminal, msg.log);
};
evtSrc.onerror = function() {
evtSrc.close();
spinner.className = 'd-none';
closeBtn.disabled = false;
appendLog(terminal, 'Connection lost — check History for result.');
};
})
.catch(err => {
statusText.textContent = 'Request failed: ' + err.message;
statusText.className = 'text-danger small';
spinner.className = 'd-none';
closeBtn.disabled = false;
});
}
function appendLog(container, text) {
const line = document.createElement('div');
line.textContent = text;
if (/CRITICAL|ERROR/.test(text)) line.className = 'log-error';
else if (/WARNING/.test(text)) line.className = 'log-warn';
else if (/complete|success|OK/i.test(text)) line.className = 'log-ok';
else line.className = 'log-info';
container.appendChild(line);
container.scrollTop = container.scrollHeight;
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>