Files
BC-bak/templates/restore_list.html
Malin b651eca285 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>
2026-06-26 20:09:32 +02:00

357 lines
14 KiB
HTML

{% 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 %}