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:
278
templates/base.html
Normal file
278
templates/base.html
Normal 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>
|
||||
163
templates/dashboard.html
Normal file
163
templates/dashboard.html
Normal file
@@ -0,0 +1,163 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-speedometer2 me-2 text-primary"></i>Dashboard</h4>
|
||||
{% if running_id %}
|
||||
<div class="text-warning small mt-1">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Backup #{{ running_id }} in progress…
|
||||
<a href="{{ url_for('history') }}" class="ms-1 link-warning">View log</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary" onclick="runBackupNow(true)" id="backupBtn">
|
||||
<i class="bi bi-cloud-upload-fill me-1"></i>Full Backup
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split"
|
||||
data-bs-toggle="dropdown" aria-expanded="false"></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" onclick="runBackupNow(false); return false;">
|
||||
<i class="bi bi-arrow-repeat me-2 text-success"></i>Incremental Backup
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-primary bg-opacity-10 text-primary">
|
||||
<i class="bi bi-archive-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small">Total Backups</div>
|
||||
<div class="fw-bold fs-4 lh-1">{{ total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-success bg-opacity-10 text-success">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small">Last Success</div>
|
||||
<div class="fw-semibold lh-1 mt-1">{{ last_ok['started_at'] | fmtdt if last_ok else '—' }}</div>
|
||||
{% if last_ok %}
|
||||
<div class="text-muted" style="font-size:.7rem">{{ last_ok['total_records'] or 0 | int }} records</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-primary bg-opacity-10 text-primary">
|
||||
<i class="bi bi-database-fill-up"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small">Last Full Backup</div>
|
||||
<div class="fw-semibold lh-1 mt-1">{{ last_full['started_at'] | fmtdt if last_full else '—' }}</div>
|
||||
{% if last_full %}
|
||||
<div class="text-muted" style="font-size:.7rem">{{ '%.1f' | format(last_full['archive_size_mb'] or 0) }} MB</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="stat-icon bg-warning bg-opacity-10 text-warning">
|
||||
<i class="bi bi-alarm-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small">Next Incremental</div>
|
||||
<div class="fw-semibold lh-1 mt-1" style="font-size:.85rem">{{ next_incr or 'Not scheduled' }}</div>
|
||||
<div class="text-muted" style="font-size:.7rem">Full: {{ next_full_run or 'Not scheduled' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent runs -->
|
||||
<div class="card stat-card">
|
||||
<div class="card-header bg-white border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold"><i class="bi bi-clock-history me-2 text-secondary"></i>Recent Backups</span>
|
||||
<a href="{{ url_for('history') }}" class="btn btn-outline-secondary btn-sm">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Started</th>
|
||||
<th>Type</th>
|
||||
<th>Trigger</th>
|
||||
<th>Companies</th>
|
||||
<th>Records</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent %}
|
||||
<tr {% if r['status'] == 'failed' %}class="table-danger bg-opacity-25"{% endif %}>
|
||||
<td class="text-muted">{{ r['id'] }}</td>
|
||||
<td>{{ r['started_at'] | fmtdt }}</td>
|
||||
<td>
|
||||
{% if r['backup_type'] == 'full' %}
|
||||
<span class="badge bg-primary"><i class="bi bi-database-fill-up me-1"></i>Full</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success"><i class="bi bi-arrow-repeat me-1"></i>Incr</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill {% if r['triggered_by'] == 'manual' %}bg-info{% else %}bg-secondary{% endif %} text-white">
|
||||
{{ r['triggered_by'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
{% if r['companies'] %}
|
||||
{{ r['companies'] | replace('["','') | replace('"]','') | replace('","',' · ') }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">{{ r['total_records'] or '—' }}</td>
|
||||
<td>{% if r['archive_size_mb'] %}{{ '%.1f' | format(r['archive_size_mb']) }} MB{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
{% if r['status'] == 'success' %}
|
||||
<span class="badge badge-success"><i class="bi bi-check me-1"></i>OK</span>
|
||||
{% elif r['status'] == 'failed' %}
|
||||
<span class="badge badge-failed"><i class="bi bi-x me-1"></i>Failed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-running"><i class="bi bi-arrow-repeat me-1"></i>Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
||||
No backups yet — click <strong>Full Backup</strong> to start.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
131
templates/history.html
Normal file
131
templates/history.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — History{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h4><i class="bi bi-clock-history me-2 text-primary"></i>Backup History</h4>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted small">{{ total }} total backup{{ 's' if total != 1 }}</span>
|
||||
<button class="btn btn-primary btn-sm px-3" onclick="runBackupNow()">
|
||||
<i class="bi bi-cloud-upload-fill me-1"></i>Backup Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card stat-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Started</th>
|
||||
<th>Completed</th>
|
||||
<th>Duration</th>
|
||||
<th>Type</th>
|
||||
<th>Trigger</th>
|
||||
<th>Companies</th>
|
||||
<th>Entities OK</th>
|
||||
<th>Records</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
<th>Archive</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in runs %}
|
||||
<tr {% if r['status'] == 'failed' %}class="table-danger bg-opacity-25"{% endif %}>
|
||||
<td class="text-muted">{{ r['id'] }}</td>
|
||||
<td>{{ r['started_at'] | fmtdt }}</td>
|
||||
<td>{{ r['completed_at'] | fmtdt }}</td>
|
||||
<td>{{ r['started_at'] | duration(r['completed_at']) }}</td>
|
||||
<td>
|
||||
{% if r['backup_type'] == 'full' %}
|
||||
<span class="badge bg-primary text-white"><i class="bi bi-database-fill-up me-1"></i>Full</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success text-white"><i class="bi bi-arrow-repeat me-1"></i>Incr</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill {% if r['triggered_by'] == 'manual' %}bg-info{% else %}bg-secondary{% endif %} text-white">
|
||||
{{ r['triggered_by'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
{% if r['companies'] %}
|
||||
{{ r['companies'] | replace('["','') | replace('"]','') | replace('","',' · ') }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-success fw-semibold">{{ r['entities_ok'] or '—' }}</td>
|
||||
<td class="text-muted">{{ r['total_records'] or '—' }}</td>
|
||||
<td>{% if r['archive_size_mb'] %}{{ '%.1f' | format(r['archive_size_mb']) }} MB{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
{% if r['status'] == 'success' %}
|
||||
<span class="badge badge-success"><i class="bi bi-check me-1"></i>Success</span>
|
||||
{% elif r['status'] == 'failed' %}
|
||||
<span class="badge badge-failed" title="{{ r['error_message'] or '' }}">
|
||||
<i class="bi bi-x me-1"></i>Failed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge badge-running"><i class="bi bi-arrow-repeat me-1"></i>Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted" style="font-size:.7rem; max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
||||
{% if r['s3_key'] %}
|
||||
<span title="{{ r['s3_key'] }}">{{ r['s3_key'].split('/')[-1] }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r['s3_key'] and r['status'] == 'success' %}
|
||||
<a href="{{ url_for('restore_list') }}"
|
||||
class="btn btn-outline-warning btn-sm py-0"
|
||||
onclick="event.preventDefault(); sessionStorage.setItem('restoreKey','{{ r['s3_key'] }}'); window.location='{{ url_for('restore_list') }}'">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if r['status'] == 'failed' and r['error_message'] %}
|
||||
<tr class="table-danger">
|
||||
<td colspan="13" class="text-danger small py-1 ps-4">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>{{ r['error_message'] }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="13" class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No backup history yet
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="card-footer bg-white border-0 d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">Page {{ page }} of {{ pages }}</span>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?page={{ page - 1 }}">‹ Prev</a>
|
||||
</li>
|
||||
{% for p in range(1, pages + 1) %}
|
||||
{% if p == page or p == 1 or p == pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% elif p == page - 3 or p == page + 3 %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?page={{ page + 1 }}">Next ›</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
356
templates/restore_list.html
Normal file
356
templates/restore_list.html
Normal file
@@ -0,0 +1,356 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Restore{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h4><i class="bi bi-arrow-counterclockwise me-2 text-warning"></i>Restore</h4>
|
||||
</div>
|
||||
|
||||
<!-- Available backups in S3 -->
|
||||
<div class="card stat-card mb-4">
|
||||
<div class="card-header bg-white border-0 pt-3 pb-2">
|
||||
<span class="fw-bold"><i class="bi bi-cloud-download me-2 text-primary"></i>Available Backups</span>
|
||||
<span class="text-muted small ms-2">— click Restore to decrypt and extract locally</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Archive</th>
|
||||
<th>Last Modified</th>
|
||||
<th>Size</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in remote_backups %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if b.get('backup_type') == 'incremental' %}
|
||||
<span class="badge bg-success me-1"><i class="bi bi-arrow-repeat me-1"></i>Incr</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary me-1"><i class="bi bi-database-fill-up me-1"></i>Full</span>
|
||||
{% endif %}
|
||||
<span class="font-monospace small">{{ b['name'] }}</span>
|
||||
</td>
|
||||
<td>{{ b['last_modified'][:16].replace('T',' ') }}</td>
|
||||
<td>{{ b['size_mb'] }} MB</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-warning btn-sm"
|
||||
onclick="startRestore(this)"
|
||||
data-s3key="{{ b['key'] }}"
|
||||
data-type="{{ b.get('backup_type', 'full') }}"
|
||||
data-chain="{{ 'true' if b.get('chain_available') else 'false' }}"
|
||||
data-chain-length="{{ b.get('chain_length', 1) }}">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
<i class="bi bi-cloud-slash fs-3 d-block mb-2"></i>
|
||||
No backups found — check your S3 settings or run a backup first.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Past restores -->
|
||||
<div class="card stat-card">
|
||||
<div class="card-header bg-white border-0 pt-3 pb-2">
|
||||
<span class="fw-bold"><i class="bi bi-folder2-open me-2 text-secondary"></i>Previous Restores</span>
|
||||
<span class="text-muted small ms-2">— extracted files ready to browse and download</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Archive</th>
|
||||
<th>Files</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in restores %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ r['id'] }}</td>
|
||||
<td>{{ r['started_at'] | fmtdt }}</td>
|
||||
<td>{{ r['started_at'] | duration(r['completed_at']) }}</td>
|
||||
<td class="font-monospace small" style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
||||
{{ r['s3_key'].split('/')[-1] }}
|
||||
</td>
|
||||
<td>{{ r['file_count'] or '—' }}</td>
|
||||
<td>
|
||||
{% if r['status'] == 'success' %}
|
||||
<span class="badge badge-success"><i class="bi bi-check me-1"></i>Ready</span>
|
||||
{% elif r['status'] == 'failed' %}
|
||||
<span class="badge badge-failed" title="{{ r['error_message'] or '' }}">
|
||||
<i class="bi bi-x me-1"></i>Failed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge badge-running"><i class="bi bi-arrow-repeat me-1"></i>Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end d-flex gap-1 justify-content-end">
|
||||
{% if r['status'] == 'success' %}
|
||||
<a href="{{ url_for('restore_view', restore_id=r['id']) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-folder2-open me-1"></i>Browse
|
||||
</a>
|
||||
<a href="{{ url_for('restore_download_zip', restore_id=r['id']) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-file-zip me-1"></i>ZIP
|
||||
</a>
|
||||
{% elif r['status'] == 'running' %}
|
||||
<a href="{{ url_for('restore_view', restore_id=r['id']) }}" class="btn btn-outline-warning btn-sm">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteRestore({{ r['id'] }}, this)"
|
||||
{% if r['status'] == 'running' %}disabled{% endif %}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">No restores yet</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore options modal -->
|
||||
<div class="modal fade" id="restoreFilterModal" tabindex="-1">
|
||||
<div class="modal-dialog 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-arrow-counterclockwise me-2 text-warning"></i>Restore Options
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Archive type info -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3 p-2 rounded bg-light">
|
||||
<span id="restoreTypeBadge" class="badge bg-primary">Full</span>
|
||||
<span id="restoreArchiveName" class="font-monospace small text-muted text-truncate"></span>
|
||||
</div>
|
||||
|
||||
<!-- Chain restore toggle (incrementals only) -->
|
||||
<div id="restoreChainSection" class="mb-3 d-none">
|
||||
<div class="form-check form-switch mb-1">
|
||||
<input class="form-check-input" type="checkbox" id="restoreChainToggle" checked
|
||||
onchange="toggleChainWarning(this.checked)">
|
||||
<label class="form-check-label fw-semibold" for="restoreChainToggle">
|
||||
Chain Restore <span id="restoreChainLabel" class="text-muted fw-normal small"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text mb-2">
|
||||
Downloads the parent full backup + all incrementals up to this point,
|
||||
merged into a complete database snapshot.
|
||||
</div>
|
||||
<div id="restorePartialWarning" class="alert alert-warning border-0 py-2 small d-none">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Partial restore</strong> — only entities changed in this specific incremental will be
|
||||
extracted. Enable Chain Restore above for a complete snapshot.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entity filter -->
|
||||
<label class="form-label fw-semibold small">
|
||||
Entity filter <span class="fw-normal text-muted">(optional)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control font-monospace" id="restoreEntityFilter"
|
||||
placeholder="customers, vendors, salesInvoices … (blank = all)">
|
||||
<div class="form-text">Comma-separated entity names to extract.</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-warning btn-sm" onclick="confirmRestore()">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Start Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore progress modal -->
|
||||
<div class="modal fade" id="restoreModal" 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-arrow-counterclockwise me-2 text-warning"></i>
|
||||
<span id="restoreModalTitle">Restore in Progress…</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="spinner-border spinner-border-sm text-warning" id="restoreSpinner"></div>
|
||||
<span id="restoreStatusText" class="text-muted small">Downloading from S3…</span>
|
||||
</div>
|
||||
<div class="log-terminal" id="restoreLog"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="restoreCloseBtn"
|
||||
data-bs-dismiss="modal" disabled>Close</button>
|
||||
<a href="#" class="btn btn-warning btn-sm d-none" id="restoreBrowseBtn">
|
||||
<i class="bi bi-folder2-open me-1"></i>Browse Files
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let _restoreModal = null;
|
||||
let _filterModal = null;
|
||||
let _pendingRestore = {};
|
||||
|
||||
function startRestore(btn) {
|
||||
const s3key = btn.dataset.s3key;
|
||||
const btype = btn.dataset.type || 'full';
|
||||
const chainAvail = btn.dataset.chain === 'true';
|
||||
const chainLength = parseInt(btn.dataset.chainLength || '1');
|
||||
|
||||
_pendingRestore = {s3key, btype, chainAvail, chainLength};
|
||||
|
||||
document.getElementById('restoreEntityFilter').value = '';
|
||||
document.getElementById('restoreArchiveName').textContent = s3key.split('/').pop();
|
||||
|
||||
const badge = document.getElementById('restoreTypeBadge');
|
||||
if (btype === 'incremental') {
|
||||
badge.textContent = 'Incremental';
|
||||
badge.className = 'badge bg-success';
|
||||
} else {
|
||||
badge.textContent = 'Full Backup';
|
||||
badge.className = 'badge bg-primary';
|
||||
}
|
||||
|
||||
const chainSection = document.getElementById('restoreChainSection');
|
||||
if (btype === 'incremental' && chainAvail) {
|
||||
chainSection.classList.remove('d-none');
|
||||
const toggle = document.getElementById('restoreChainToggle');
|
||||
toggle.checked = true;
|
||||
toggle.disabled = false;
|
||||
document.getElementById('restoreChainLabel').textContent =
|
||||
`— ${chainLength} archive${chainLength > 1 ? 's' : ''} (1 full + ${chainLength - 1} incr)`;
|
||||
document.getElementById('restorePartialWarning').classList.add('d-none');
|
||||
} else if (btype === 'incremental' && !chainAvail) {
|
||||
chainSection.classList.remove('d-none');
|
||||
const toggle = document.getElementById('restoreChainToggle');
|
||||
toggle.checked = false;
|
||||
toggle.disabled = true;
|
||||
document.getElementById('restoreChainLabel').textContent = '— not available (run a full backup first)';
|
||||
document.getElementById('restorePartialWarning').classList.remove('d-none');
|
||||
} else {
|
||||
chainSection.classList.add('d-none');
|
||||
}
|
||||
|
||||
if (!_filterModal) _filterModal = new bootstrap.Modal(document.getElementById('restoreFilterModal'));
|
||||
_filterModal.show();
|
||||
}
|
||||
|
||||
function toggleChainWarning(on) {
|
||||
document.getElementById('restorePartialWarning').classList.toggle('d-none', on);
|
||||
}
|
||||
|
||||
function confirmRestore() {
|
||||
const entityFilter = document.getElementById('restoreEntityFilter').value.trim();
|
||||
const useChain = document.getElementById('restoreChainToggle')?.checked ?? false;
|
||||
if (_filterModal) _filterModal.hide();
|
||||
_doRestore(_pendingRestore.s3key, entityFilter, useChain);
|
||||
}
|
||||
|
||||
function _doRestore(s3Key, entityFilter, useChain) {
|
||||
const terminal = document.getElementById('restoreLog');
|
||||
const statusText = document.getElementById('restoreStatusText');
|
||||
const spinner = document.getElementById('restoreSpinner');
|
||||
const closeBtn = document.getElementById('restoreCloseBtn');
|
||||
const browseBtn = document.getElementById('restoreBrowseBtn');
|
||||
const title = document.getElementById('restoreModalTitle');
|
||||
|
||||
terminal.innerHTML = '';
|
||||
spinner.className = 'spinner-border spinner-border-sm text-warning';
|
||||
statusText.textContent = 'Downloading from S3…';
|
||||
statusText.className = 'text-muted small';
|
||||
closeBtn.disabled = true;
|
||||
browseBtn.classList.add('d-none');
|
||||
title.textContent = 'Restore in Progress…';
|
||||
|
||||
if (!_restoreModal) _restoreModal = new bootstrap.Modal(document.getElementById('restoreModal'));
|
||||
_restoreModal.show();
|
||||
|
||||
fetch('/api/restore', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({s3_key: s3Key, entities: entityFilter || '', chain: !!useChain}),
|
||||
})
|
||||
.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 restoreId = data.restore_id;
|
||||
statusText.textContent = 'Restore #' + restoreId + ' running…';
|
||||
|
||||
const evtSrc = new EventSource('/api/restore/' + restoreId + '/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;
|
||||
if (msg.status === 'success') {
|
||||
title.textContent = 'Restore Complete';
|
||||
statusText.innerHTML = '<i class="bi bi-check-circle-fill text-success me-1"></i><span class="text-success fw-semibold">Files extracted and ready to browse.</span>';
|
||||
browseBtn.href = '/restore/' + restoreId;
|
||||
browseBtn.classList.remove('d-none');
|
||||
} else {
|
||||
title.textContent = 'Restore Failed';
|
||||
statusText.innerHTML = '<i class="bi bi-x-circle-fill text-danger me-1"></i><span class="text-danger fw-semibold">Restore failed — see log above.</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.log) appendLog(document.getElementById('restoreLog'), msg.log);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
const key = sessionStorage.getItem('restoreKey');
|
||||
if (key) {
|
||||
sessionStorage.removeItem('restoreKey');
|
||||
_doRestore(key, '', false);
|
||||
}
|
||||
});
|
||||
|
||||
function deleteRestore(id, btn) {
|
||||
if (!confirm('Delete this restore and all extracted files?')) return;
|
||||
btn.disabled = true;
|
||||
fetch('/api/restore/' + id, {method: 'DELETE'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) btn.closest('tr').remove();
|
||||
else { btn.disabled = false; alert('Delete failed: ' + (data.error || 'unknown error')); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
165
templates/restore_view.html
Normal file
165
templates/restore_view.html
Normal file
@@ -0,0 +1,165 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Restore #{{ restore['id'] }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-folder2-open me-2 text-warning"></i>Restore #{{ restore['id'] }}
|
||||
</h4>
|
||||
<div class="text-muted small mt-1">
|
||||
{{ restore['s3_key'].split('/')[-1] }} ·
|
||||
Started {{ restore['started_at'] | fmtdt }} ·
|
||||
{{ restore['started_at'] | duration(restore['completed_at']) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if restore['status'] == 'success' %}
|
||||
<a href="{{ url_for('restore_download_zip', restore_id=restore['id']) }}"
|
||||
class="btn btn-outline-primary">
|
||||
<i class="bi bi-file-zip me-1"></i>Download All (ZIP)
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('restore_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>All Restores
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if streaming_id %}
|
||||
<!-- Still running — show live log -->
|
||||
<div class="card stat-card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="spinner-border spinner-border-sm text-warning"></div>
|
||||
<span id="liveStatus" class="text-muted">Restore in progress…</span>
|
||||
</div>
|
||||
<div class="log-terminal" id="liveLog"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif restore['status'] == 'failed' %}
|
||||
<div class="alert alert-danger border-0 shadow-sm">
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
<strong>Restore failed:</strong> {{ restore['error_message'] or 'Unknown error' }}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Success — restore type banner -->
|
||||
{% set rmode = restore_info.get('restore_mode', 'full') %}
|
||||
{% if rmode == 'chain' %}
|
||||
<div class="alert alert-success border-0 shadow-sm mb-4">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
<strong>Chain Restore</strong> — {{ restore_info.get('chain_length', 1) }} archives merged
|
||||
(1 full + {{ restore_info.get('chain_length', 1) - 1 }} incremental).
|
||||
<span class="ms-2 text-success fw-semibold">{{ restore['file_count'] }} entity files — complete snapshot.</span>
|
||||
</div>
|
||||
{% elif rmode == 'incremental' %}
|
||||
<div class="alert alert-warning border-0 shadow-sm mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Partial Restore</strong> — this is a single incremental archive.
|
||||
Only <strong>{{ restore['file_count'] }} entity files</strong> with changes in that specific run are present.
|
||||
<span class="ms-2">Use <strong>Chain Restore</strong> from the Restore page for a complete snapshot.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success border-0 shadow-sm mb-4">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
<strong>Full Snapshot</strong> — <strong>{{ restore['file_count'] }} entity files</strong> extracted.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if by_company %}
|
||||
<!-- Search box -->
|
||||
<div class="mb-3">
|
||||
<input type="text" id="entitySearch" class="form-control"
|
||||
placeholder="Filter entities… (e.g. customers, salesInvoices)">
|
||||
</div>
|
||||
|
||||
<div class="accordion" id="companyAccordion">
|
||||
{% for company in by_company | sort %}
|
||||
{% set entities = by_company[company] %}
|
||||
<div class="accordion-item border-0 mb-2 shadow-sm rounded overflow-hidden">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button fw-semibold" type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#collapse-{{ loop.index }}"
|
||||
aria-expanded="true">
|
||||
<i class="bi bi-building me-2 text-primary"></i>
|
||||
{{ company }}
|
||||
<span class="badge bg-primary bg-opacity-15 text-primary ms-2">
|
||||
{{ entities | length }} entities
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse-{{ loop.index }}" class="accordion-collapse collapse show">
|
||||
<div class="accordion-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0 table-files">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Entity</th>
|
||||
<th>Format</th>
|
||||
<th>Size</th>
|
||||
<th class="text-end"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in entities %}
|
||||
<tr data-entity="{{ f['entity'] }}">
|
||||
<td class="font-monospace">{{ f['entity'] }}</td>
|
||||
<td><span class="badge bg-secondary bg-opacity-15 text-secondary">{{ f['ext'] }}</span></td>
|
||||
<td class="text-muted small">{{ f['size_kb'] }} KB</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('restore_download_file', restore_id=restore['id']) }}?path={{ f['path'] | urlencode }}"
|
||||
class="btn btn-outline-secondary btn-sm py-0">
|
||||
<i class="bi bi-download me-1"></i>Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted text-center py-4">No entity files found in this restore.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if streaming_id %}
|
||||
<script>
|
||||
const liveLog = document.getElementById('liveLog');
|
||||
const liveStatus = document.getElementById('liveStatus');
|
||||
|
||||
const evtSrc = new EventSource('/api/restore/{{ streaming_id }}/stream');
|
||||
evtSrc.onmessage = function(e) {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.keepalive) return;
|
||||
if (msg.done) {
|
||||
evtSrc.close();
|
||||
liveStatus.textContent = msg.status === 'success' ? 'Restore complete — reloading…' : 'Restore failed.';
|
||||
if (msg.status === 'success') setTimeout(() => location.reload(), 1500);
|
||||
return;
|
||||
}
|
||||
if (msg.log) appendLog(liveLog, msg.log);
|
||||
};
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if by_company %}
|
||||
<script>
|
||||
document.getElementById('entitySearch').addEventListener('input', function() {
|
||||
const q = this.value.toLowerCase();
|
||||
document.querySelectorAll('.table-files tbody tr').forEach(row => {
|
||||
row.style.display = row.dataset.entity.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
259
templates/settings.html
Normal file
259
templates/settings.html
Normal file
@@ -0,0 +1,259 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h4><i class="bi bi-gear me-2 text-primary"></i>Settings</h4>
|
||||
<button form="settingsForm" type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-floppy me-2"></i>Save Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" id="settingsForm">
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Azure AD -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||
<i class="bi bi-microsoft me-2 text-primary"></i>Azure AD Application
|
||||
</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
Create an App Registration in Azure AD with <strong>API.ReadWrite.All</strong>
|
||||
permission on Dynamics 365 Business Central and admin consent granted.
|
||||
</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Tenant ID (Directory ID)</label>
|
||||
<input type="text" name="azure_tenant_id" class="form-control font-monospace"
|
||||
value="{{ cfg.get('azure_tenant_id', '') }}"
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Client ID (Application ID)</label>
|
||||
<input type="text" name="azure_client_id" class="form-control font-monospace"
|
||||
value="{{ cfg.get('azure_client_id', '') }}"
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Client Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="azure_client_secret" id="azureSecret" class="form-control"
|
||||
value="{{ cfg.get('azure_client_secret', '') }}"
|
||||
placeholder="Azure AD client secret value" required>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('azureSecret', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Central -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||
<i class="bi bi-building me-2 text-success"></i>Business Central
|
||||
</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Environment Name</label>
|
||||
<input type="text" name="bc_environment_name" class="form-control"
|
||||
value="{{ cfg.get('bc_environment_name', 'Production') }}"
|
||||
placeholder="Production" required>
|
||||
<div class="form-text">Found in BC Admin Center — typically "Production" or "Sandbox".</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">
|
||||
Company Name <span class="fw-normal text-muted">(optional)</span>
|
||||
</label>
|
||||
<input type="text" name="bc_company_name" class="form-control"
|
||||
value="{{ cfg.get('bc_company_name', '') }}"
|
||||
placeholder="Leave blank to back up all companies">
|
||||
<div class="form-text">Filter backup to a specific company. Leave blank for all.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- S3 / MinIO -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||
<i class="bi bi-hdd-stack-fill me-2 text-warning"></i>S3 / MinIO Storage
|
||||
</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Endpoint URL</label>
|
||||
<input type="url" name="s3_endpoint" class="form-control"
|
||||
value="{{ cfg.get('s3_endpoint', '') }}"
|
||||
placeholder="https://minio.your-domain.com:9000" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Access Key</label>
|
||||
<input type="text" name="s3_access_key" class="form-control"
|
||||
value="{{ cfg.get('s3_access_key', '') }}"
|
||||
placeholder="Access key / username" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Secret Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="s3_secret_key" id="s3Secret" class="form-control"
|
||||
value="{{ cfg.get('s3_secret_key', '') }}"
|
||||
placeholder="Secret key / password" required>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('s3Secret', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<label class="form-label small fw-semibold">Bucket Name</label>
|
||||
<input type="text" name="s3_bucket" class="form-control"
|
||||
value="{{ cfg.get('s3_bucket', 'bcbak') }}" required>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small fw-semibold">Region</label>
|
||||
<input type="text" name="s3_region" class="form-control"
|
||||
value="{{ cfg.get('s3_region', 'us-east-1') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Encryption -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||
<i class="bi bi-shield-lock-fill me-2 text-success"></i>Encryption
|
||||
</h6>
|
||||
<div class="alert alert-warning border-0 small py-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Store this passphrase securely and separately from your backups — losing it means
|
||||
losing access to all encrypted archives. Backups use GPG symmetric AES256.
|
||||
</div>
|
||||
<label class="form-label small fw-semibold">GPG Passphrase</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="encryption_passphrase" id="encPassphrase" class="form-control"
|
||||
value="{{ cfg.get('encryption_passphrase', '') }}"
|
||||
placeholder="Strong random passphrase" required>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('encPassphrase', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="generatePassphrase()">
|
||||
<i class="bi bi-shuffle"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">GPG symmetric AES256 · compatible with <code>gpg --decrypt</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incremental Schedule -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||
<i class="bi bi-arrow-repeat me-2 text-success"></i>Incremental Backup Schedule
|
||||
</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
Exports only records modified since the last successful run
|
||||
(<code>lastModifiedDateTime</code> filter). Run frequently to minimize data loss.
|
||||
</p>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="schedule_incr_enabled" id="incrToggle"
|
||||
{% if cfg.get('schedule_incr_enabled', 'false') == 'true' %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="incrToggle">Enable incremental schedule</label>
|
||||
</div>
|
||||
<label class="form-label small fw-semibold">Cron Expression</label>
|
||||
<input type="text" name="schedule_incr_cron" id="incrCron" class="form-control font-monospace"
|
||||
value="{{ cfg.get('schedule_incr_cron', '0 * * * *') }}"
|
||||
placeholder="0 * * * *">
|
||||
<div class="form-text">
|
||||
<code>0 * * * *</code> — every hour <code>0 */4 * * *</code> — every 4 h<br>
|
||||
<a href="https://crontab.guru/" target="_blank" rel="noopener">crontab.guru ↗</a>
|
||||
</div>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 * * * *')">Every hour</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 */2 * * *')">Every 2 h</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 */4 * * *')">Every 4 h</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 8-20 * * 1-5')">Business hours</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Backup Schedule -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||||
<i class="bi bi-database-fill-up me-2 text-primary"></i>Full Backup Schedule
|
||||
</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
Complete export of all entities and all companies — no date filter applied.
|
||||
Run weekly to provide a chain restore baseline.
|
||||
</p>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="schedule_full_enabled" id="fullToggle"
|
||||
{% if cfg.get('schedule_full_enabled', 'false') == 'true' %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="fullToggle">Enable full backup schedule</label>
|
||||
</div>
|
||||
<label class="form-label small fw-semibold">Cron Expression</label>
|
||||
<input type="text" name="schedule_full_cron" id="fullCron" class="form-control font-monospace"
|
||||
value="{{ cfg.get('schedule_full_cron', '0 1 * * 0') }}"
|
||||
placeholder="0 1 * * 0">
|
||||
<div class="form-text">
|
||||
<code>0 1 * * 0</code> — Sunday 01:00 <code>0 2 1 * *</code> — 1st of month 02:00<br>
|
||||
<a href="https://crontab.guru/" target="_blank" rel="noopener">crontab.guru ↗</a>
|
||||
</div>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 1 * * 0')">Weekly Sun 01:00</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 1 * * 1')">Weekly Mon 01:00</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 2 1 * *')">Monthly 1st 02:00</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3 gap-2">
|
||||
<button form="settingsForm" type="submit" class="btn btn-primary px-5">
|
||||
<i class="bi bi-floppy me-2"></i>Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(id, btn) {
|
||||
const field = document.getElementById(id);
|
||||
const isPass = field.type === 'password';
|
||||
field.type = isPass ? 'text' : 'password';
|
||||
btn.innerHTML = isPass ? '<i class="bi bi-eye-slash"></i>' : '<i class="bi bi-eye"></i>';
|
||||
}
|
||||
|
||||
function generatePassphrase() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
const pass = Array.from(arr, b => chars[b % chars.length]).join('');
|
||||
const field = document.getElementById('encPassphrase');
|
||||
field.value = pass;
|
||||
field.type = 'text';
|
||||
}
|
||||
|
||||
function setCron(fieldId, expr) {
|
||||
document.getElementById(fieldId).value = expr;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user