feat: Business Central backup Docker container with web management panel

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

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

278
templates/base.html Normal file
View File

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

163
templates/dashboard.html Normal file
View 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
View 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
View 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
View 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
View 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 &nbsp; <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 &nbsp; <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 %}