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:
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 %}
|
||||
Reference in New Issue
Block a user