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>
357 lines
14 KiB
HTML
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 %}
|