feat: Sync/Clone-Modus, Rotation, --setup Deployment, Scheduler-Fix, Dashboard-Updates, README

This commit is contained in:
Björn Nehlsen
2026-08-11 22:45:58 +02:00
parent 2eee55384e
commit a495570b20
14 changed files with 3851 additions and 681 deletions
+656 -81
View File
@@ -3,107 +3,682 @@
{% block title %}StFV Backup - Backup bearbeiten{% endblock %}
{% block content %}
<h2>Backup bearbeiten</h2>
<p>Hier kannst du Name, Ziel, Verschlüsselung und Zeitplan anpassen.</p>
<h2 style="margin-bottom:4px;">Backup bearbeiten</h2>
<form method="post" action="{{ url_for('backup_edit', backup_id=backup.id) }}">
<div class="card">
<label for="name">Name</label>
<style>
.wizard-step { display: none; }
.wizard-step.active { display: block; animation: fade-slide 240ms ease; }
.wizard-nav { margin-top: 0; margin-bottom: 8px; display: flex; gap: 8px; flex-wrap: wrap; }
.wizard-pill { border: 1px solid rgba(30,42,59,0.2); padding: 6px 10px; border-radius: 999px; font-size: 12px; color: #5a6e87; background: #fff; cursor: pointer; }
.wizard-pill.active { border-color: rgba(10,127,104,0.35); background: #e5fff7; color: #0a7f68; font-weight: 700; }
.step-actions { margin-top: 12px; display: flex; gap: 10px; align-items: center; }
.btn-secondary { border: 1px solid rgba(30,42,59,0.2); background: #fff; color: #1e2a3b; }
.tree { list-style: none; margin: 8px 0 0; padding: 0 0 0 8px; border-left: 1px solid rgba(30,42,59,0.15); }
.tree li { margin: 4px 0; padding-left: 10px; position: relative; }
.tree li::before { content: ""; position: absolute; left: -8px; top: 12px; width: 8px; border-top: 1px solid rgba(30,42,59,0.15); }
.tree-node { border: 0; background: transparent; color: #1e2a3b; font-weight: 600; cursor: pointer; padding: 3px 4px; border-radius: 6px; }
.tree-node:hover { background: #eef6ff; }
.tree-node.active { background: #e5fff7; color: #0a7f68; }
.tree-meta { font-size: 13px; color: #5a6e87; margin-top: 6px; }
.tree-empty { color: #5a6e87; font-size: 13px; }
.browser-overlay { position: fixed; inset: 0; background: rgba(10,20,34,0.52); display: none; align-items: center; justify-content: center; z-index: 1100; padding: 20px; }
.browser-dialog { background: #fff; border-radius: 16px; width: min(92vw,560px); max-height: 74vh; display: flex; flex-direction: column; box-shadow: 0 20px 48px rgba(30,42,59,0.24); overflow: hidden; position: relative; }
.browser-dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid rgba(30,42,59,0.12); }
.browser-dialog-body { padding: 12px 16px 16px; overflow: auto; }
.browser-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 16px 14px; border-top: 1px solid rgba(30,42,59,0.12); }
.browser-dialog-actions .btn-secondary { background: #fff; }
.browser-dialog-right { display: flex; gap: 8px; }
.create-folder-overlay { position: absolute; inset: 0; background: rgba(255,255,255,0.97); backdrop-filter: blur(4px); z-index: 20; display: flex; align-items: center; justify-content: center; border-radius: 16px; }
.create-folder-box { padding: 20px; width: 100%; max-width: 320px; display: grid; gap: 10px; }
.create-folder-box strong { font-size: 15px; }
.create-folder-actions { display: flex; gap: 8px; justify-content: flex-end; }
.step-header { margin: -16px -16px 14px; padding: 12px 16px; border-bottom: 1px solid rgba(30,42,59,0.08); background: rgba(30,42,59,0.025); border-radius: 14px 14px 0 0; }
.step-header h3 { margin: 0; }
.token-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.token-chip { display: inline-flex; align-items: center; padding: 4px 11px; border-radius: 999px; background: #e5fff7; border: 1px solid rgba(10,127,104,0.35); color: #0a7f68; font-size: 12px; font-weight: 700; cursor: grab; user-select: none; }
.token-chip:hover { background: #c8f5e8; }
.token-menu { position: fixed; z-index: 3000; background: #fff; border: 1px solid rgba(30,42,59,0.15); border-radius: 12px; box-shadow: 0 8px 24px rgba(30,42,59,0.18); padding: 6px; min-width: 190px; }
.token-menu-item { padding: 7px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; }
.token-menu-item:hover { background: #f0f7ff; }
.token-menu-sub { font-size: 11px; color: #5a6e87; font-weight: 400; margin-top: 1px; }
.row-3 { grid-template-columns: minmax(90px,0.8fr) 2fr 1.4fr; }
@media (max-width: 700px) { .row-3 { grid-template-columns: 1fr; } }
.path-input-group { display: flex; gap: 6px; margin-bottom: 12px; }
.path-input-group input { flex: 1; margin-bottom: 0; }
.btn-browse { padding: 8px 11px; border-radius: 12px; border: 1px solid rgba(30,42,59,0.2); background: #fff; cursor: pointer; font-size: 15px; line-height: 1; color: #1e2a3b; flex-shrink: 0; }
.btn-browse:hover { background: #f0f7ff; }
.hidden-toggle { font-size: 12px; color: #5a6e87; display: flex; align-items: center; gap: 5px; cursor: pointer; font-weight: 600; }
.hidden-toggle input[type="checkbox"] { width: auto; margin: 0; cursor: pointer; }
@keyframes fade-slide { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
</style>
<form method="post" action="{{ url_for('backup_edit', backup_id=backup.id) }}" id="backupForm">
<input type="hidden" id="source_entry_type" name="source_entry_type" value="{{ backup.source_entry_type }}">
<input type="hidden" id="target_kind" name="target_kind" value="{{ backup.target_kind }}">
<div class="wizard-nav">
<button type="button" class="wizard-pill active" data-step-pill="0" onclick="showStep(0)">Allgemein</button>
<button type="button" class="wizard-pill" data-step-pill="1" onclick="showStep(1)">Source</button>
<button type="button" class="wizard-pill" data-step-pill="2" onclick="showStep(2)">Destination</button>
<button type="button" class="wizard-pill" data-step-pill="3" onclick="showStep(3)">Target</button>
<button type="button" class="wizard-pill" data-step-pill="4" onclick="showStep(4)">Zeitplan</button>
</div>
<section class="card wizard-step active" data-step="0">
<div class="step-header"><h3>Allgemein</h3></div>
<label for="name">Backup Name</label>
<input id="name" name="name" type="text" value="{{ backup.name }}" required>
<label for="target_pattern">Zielordner / Zieldatei</label>
<input id="target_pattern" name="target_pattern" type="text" value="{{ backup.target_pattern }}" required>
<label for="compression_method">Kompression</label>
<select id="compression_method" name="compression_method" required>
{% for method in compression_methods %}
<option value="{{ method }}" {% if method == backup.compression_method %}selected{% endif %}>{{ method }}</option>
{% endfor %}
</select>
<label for="encryption_mode">Verschlüsselung</label>
<select id="encryption_mode" name="encryption_mode" required>
{% for mode in encryption_modes %}
<option value="{{ mode }}" {% if mode == backup.encryption_mode %}selected{% endif %}>{{ mode }}</option>
{% endfor %}
</select>
<div id="password_box">
<label for="archive_password">Neues Passwort (optional)</label>
<input id="archive_password" name="archive_password" type="password" placeholder="Leer lassen = bestehendes Passwort behalten">
<label for="archive_password_confirm">Passwort bestätigen</label>
<input id="archive_password_confirm" name="archive_password_confirm" type="password">
<div class="step-actions">
<button type="button" class="btn" onclick="nextStep(0)">Weiter</button>
</div>
</div>
</section>
<div class="card">
<h3>Zeitplan</h3>
<label for="schedule_mode">Ausführung</label>
<select id="schedule_mode" name="schedule_mode" required>
{% for mode in schedule_modes %}
<option value="{{ mode }}" {% if mode == backup.schedule_mode %}selected{% endif %}>{{ mode }}</option>
{% endfor %}
</select>
<div id="schedule_time_box">
<label for="schedule_time">Uhrzeit</label>
<input id="schedule_time" name="schedule_time" type="time" value="{{ backup.schedule_time or '02:00' }}">
<section class="card wizard-step" data-step="1">
<div class="step-header"><h3>Source</h3></div>
<div class="row row-3">
<div>
<label for="source_protocol">Typ</label>
<select id="source_protocol" name="source_protocol" required>
{% for p in target_protocols %}<option value="{{ p }}" {% if p == backup.source_protocol %}selected{% endif %}>{{ p }}</option>{% endfor %}
</select>
</div>
<div id="source_host_col" style="display:none;">
<label for="source_host">Host/IP</label>
<div style="display:flex; gap:6px;">
<input id="source_host" name="source_host" type="text" value="{{ backup.source_host }}" style="flex:1; margin-bottom:0;">
<input id="source_port_ui" type="number" min="1" max="65535" placeholder="Port"
title="Port" value="{{ backup.source_port or '' }}" style="width:72px; margin-bottom:0; display:none;">
</div>
</div>
<div id="source_share_row" style="display:none;">
<label for="source_share">Freigabe</label>
<input id="source_share" name="source_share" type="text" value="{{ backup.source_share }}">
</div>
<div id="source_local_path_col" style="display:none; grid-column: 2 / -1;">
<label for="source_local_path">Lokaler Pfad</label>
<div class="path-input-group">
<input id="source_local_path" type="text" value="{{ backup.source_local_path }}">
<button type="button" class="btn-browse" onclick="openLocalBrowserOverlay('source')" title="Browser öffnen">🔍</button>
</div>
</div>
</div>
<div id="schedule_weekday_box" style="display:none;">
<label for="schedule_weekday">Wochentag</label>
<select id="schedule_weekday" name="schedule_weekday">
<option value="1" {% if backup.schedule_weekday == '1' %}selected{% endif %}>Montag</option>
<option value="2" {% if backup.schedule_weekday == '2' %}selected{% endif %}>Dienstag</option>
<option value="3" {% if backup.schedule_weekday == '3' %}selected{% endif %}>Mittwoch</option>
<option value="4" {% if backup.schedule_weekday == '4' %}selected{% endif %}>Donnerstag</option>
<option value="5" {% if backup.schedule_weekday == '5' %}selected{% endif %}>Freitag</option>
<option value="6" {% if backup.schedule_weekday == '6' %}selected{% endif %}>Samstag</option>
<option value="0" {% if backup.schedule_weekday == '0' %}selected{% endif %}>Sonntag</option>
</select>
<div id="source_local_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
<div class="browser-dialog-header"><strong>Lokaler Browser</strong><label class="hidden-toggle"><input type="checkbox" id="source_local_show_hidden" onchange="refreshLocalBrowserLevel('source')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('source')">×</button></div>
<div class="browser-dialog-body"><div id="source_local_browser_meta" class="tree-meta"></div><ul id="source_local_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div></div>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('source')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptLocalBrowserSelection('source')">Auswählen</button>
</div>
</div>
</div>
</div>
<div id="source_remote_fields" style="display:none;">
<div class="row">
<div><label for="source_username">Benutzer</label><input id="source_username" name="source_username" type="text" value="{{ backup.source_username }}"></div>
<div><label for="source_password">Passwort</label><input id="source_password" name="source_password" type="password" value="{{ backup.source_password }}"></div>
</div>
<label for="source_subpath">Pfad</label>
<div class="path-input-group">
<input id="source_subpath" name="source_subpath" type="text" value="{{ backup.source_subpath }}">
<button type="button" class="btn-browse" onclick="browseSmb('source')" title="Browser öffnen">🔍</button>
</div>
<p id="source_test_message"></p>
<div id="source_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Browser">
<div class="browser-dialog-header"><strong>Browser</strong><label class="hidden-toggle"><input type="checkbox" id="source_smb_show_hidden" onchange="refreshBrowserLevel('source')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('source')">×</button></div>
<div class="browser-dialog-body"><div id="source_browser_meta" class="tree-meta"></div><ul id="source_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div></div>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('source')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptBrowserSelection('source')">Auswählen</button>
</div>
</div>
</div>
</div>
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(1)">Zurück</button>
<button type="button" class="btn" onclick="nextStep(1)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="2">
<div class="step-header"><h3>Destination</h3></div>
<div class="row row-3">
<div>
<label for="destination_protocol">Typ</label>
<select id="destination_protocol" name="destination_protocol" required>
{% for p in target_protocols %}<option value="{{ p }}" {% if p == backup.destination_protocol %}selected{% endif %}>{{ p }}</option>{% endfor %}
</select>
</div>
<div id="destination_host_col" style="display:none;">
<label for="destination_host">Host/IP</label>
<div style="display:flex; gap:6px;">
<input id="destination_host" name="destination_host" type="text" value="{{ backup.destination_host }}" style="flex:1; margin-bottom:0;">
<input id="destination_port_ui" type="number" min="1" max="65535" placeholder="Port"
title="Port" value="{{ backup.destination_port or '' }}" style="width:72px; margin-bottom:0; display:none;">
</div>
</div>
<div id="destination_share_row" style="display:none;">
<label for="destination_share">Freigabe</label>
<input id="destination_share" name="destination_share" type="text" value="{{ backup.destination_share }}">
</div>
<div id="destination_local_path_col" style="display:none; grid-column: 2 / -1;">
<label for="destination_local_path">Lokaler Pfad</label>
<div class="path-input-group">
<input id="destination_local_path" type="text" value="{{ backup.destination_local_path }}">
<button type="button" class="btn-browse" onclick="openLocalBrowserOverlay('destination')" title="Browser öffnen">🔍</button>
</div>
</div>
</div>
<div id="schedule_dom_box" style="display:none;">
<label for="schedule_day_of_month">Tag im Monat</label>
<input id="schedule_day_of_month" name="schedule_day_of_month" type="number" min="1" max="31" value="{{ backup.schedule_day_of_month or '1' }}">
<div id="destination_local_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
<div class="browser-dialog-header"><strong>Lokaler Browser</strong><label class="hidden-toggle"><input type="checkbox" id="destination_local_show_hidden" onchange="refreshLocalBrowserLevel('destination')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('destination')">×</button></div>
<div class="browser-dialog-body"><div id="destination_local_browser_meta" class="tree-meta"></div><ul id="destination_local_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div id="destination_local_new_folder_bar" style="display:none; flex:1; align-items:center; gap:6px;">
<input id="destination_local_new_folder" type="text" placeholder="Ordnername" autocomplete="off" style="flex:1; min-width:0; margin:0;">
<button class="btn" type="button" onclick="createLocalDestinationFolder()">Erstellen</button>
<button class="btn btn-secondary" type="button" onclick="closeLocalCreateFolderOverlay()">&#x2715;</button>
</div>
<button id="destination_local_new_folder_btn" type="button" class="btn btn-secondary" onclick="openLocalCreateFolderOverlay()">Ordner erstellen</button>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('destination')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptLocalBrowserSelection('destination')">Auswählen</button>
</div>
</div>
</div>
</div>
<div id="destination_remote_fields" style="display:none;">
<div class="row">
<div><label for="destination_username">Benutzer</label><input id="destination_username" name="destination_username" type="text" value="{{ backup.destination_username }}"></div>
<div><label for="destination_password">Passwort</label><input id="destination_password" name="destination_password" type="password" value="{{ backup.destination_password }}"></div>
</div>
<label for="destination_subpath">Pfad</label>
<div class="path-input-group">
<input id="destination_subpath" name="destination_subpath" type="text" value="{{ backup.destination_subpath }}">
<button type="button" class="btn-browse" onclick="browseSmb('destination')" title="Browser öffnen">🔍</button>
</div>
<p id="destination_test_message"></p>
<div id="destination_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Browser">
<div class="browser-dialog-header"><strong>Browser</strong><label class="hidden-toggle"><input type="checkbox" id="destination_smb_show_hidden" onchange="refreshBrowserLevel('destination')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('destination')">×</button></div>
<div class="browser-dialog-body"><div id="destination_browser_meta" class="tree-meta"></div><ul id="destination_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div id="destination_new_folder_bar" style="display:none; flex:1; align-items:center; gap:6px;">
<input id="destination_new_folder" type="text" placeholder="Ordnername" autocomplete="off" style="flex:1; min-width:0; margin:0;">
<button class="btn" type="button" onclick="createDestinationFolder()">Erstellen</button>
<button class="btn btn-secondary" type="button" onclick="closeCreateFolderOverlay()">&#x2715;</button>
</div>
<button id="destination_new_folder_btn" type="button" class="btn btn-secondary" onclick="openCreateFolderOverlay()">Ordner erstellen</button>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('destination')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptBrowserSelection('destination')">Auswählen</button>
</div>
</div>
</div>
</div>
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(2)">Zurück</button>
<button type="button" class="btn" onclick="nextStep(2)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="3">
<div class="step-header"><h3>Target</h3></div>
<div id="target_pattern_box">
<label for="target_pattern">Ziel</label>
<div class="token-bar" id="token_bar"></div>
<input id="target_pattern" name="target_pattern" type="text" value="{{ backup.target_pattern }}" required>
<div id="token_context_menu" class="token-menu" style="display:none;"></div>
</div>
<div id="schedule_interval_box" style="display:none;">
<label for="schedule_interval">Intervall (X)</label>
<input id="schedule_interval" name="schedule_interval" type="number" min="1" value="{{ backup.schedule_interval or 1 }}">
<div class="row" style="grid-template-columns: 1fr 120px 1fr;">
<div>
<label for="backup_mode">Backup-Modus</label>
<select id="backup_mode" name="backup_mode" onchange="syncBackupMode()">
{% for mode in backup_modes %}
<option value="{{ mode }}" {% if mode == backup.backup_mode %}selected{% endif %}>{% if mode == 'full' %}Vollständig{% elif mode == 'sync' %}Sync{% else %}Clone{% endif %}</option>
{% endfor %}
</select>
</div>
<div id="rotation_col">
<label for="rotation_keep">Rotation (0=∞)</label>
<input id="rotation_keep" name="rotation_keep" type="number" min="0" value="{{ backup.rotation_keep }}" style="width:100%;">
</div>
<div id="compression_col">
<label for="compression_method">Kompression</label>
<select id="compression_method" name="compression_method" required>
{% for m in compression_methods %}<option value="{{ m }}" {% if m == backup.compression_method %}selected{% endif %}>{% if m == 'none' %}Keine (1:1){% else %}{{ m }}{% endif %}</option>{% endfor %}
</select>
</div>
</div>
<div id="schedule_cron_box" style="display:none;">
<label for="schedule_cron">CRON Ausdruck</label>
<input id="schedule_cron" name="schedule_cron" type="text" value="{{ backup.schedule_cron or '' }}">
<div id="encryption_row" class="row" style="display:none; grid-template-columns: 1fr 1fr 1fr;">
<div>
<label for="encryption_mode">Verschlüsselung</label>
<select id="encryption_mode" name="encryption_mode" required>
{% for m in encryption_modes %}<option value="{{ m }}" {% if m == backup.encryption_mode %}selected{% endif %}>{{ m }}</option>{% endfor %}
</select>
</div>
<div id="archive_pwd_col" style="display:none;">
<label for="archive_password">Passwort</label>
<input id="archive_password" name="archive_password" type="password" value="{{ backup.archive_password }}">
</div>
<div id="archive_pwd2_col" style="display:none;">
<label for="archive_password_confirm">Bestätigen</label>
<input id="archive_password_confirm" name="archive_password_confirm" type="password" value="{{ backup.archive_password }}">
</div>
</div>
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(3)">Zurück</button>
<button type="button" class="btn" onclick="nextStep(3)">Weiter</button>
</div>
</section>
<div class="step-actions">
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}" style="text-decoration:none;">Abbrechen</a>
<button class="btn" type="submit">Speichern</button>
</div>
<section class="card wizard-step" data-step="4">
<div class="step-header"><h3>Zeitplan</h3></div>
<label for="schedule_cron">CRON Ausdruck</label>
<input id="schedule_cron" name="schedule_cron" type="text" value="{{ backup.schedule_cron }}">
<div id="cron_preview" style="display:none;margin-top:6px;font-size:13px;color:#1e2a3b;">
<div id="cron_description" style="font-weight:600;"></div>
<div id="cron_next_run" style="color:#5a6e87;"></div>
</div>
<div id="cron_error" style="display:none;margin-top:6px;font-size:13px;color:#b9382f;"></div>
<div class="step-actions">
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}" style="text-decoration:none;">Abbrechen</a>
<button type="button" class="btn btn-secondary" onclick="prevStep(4)">Zurück</button>
<button class="btn" type="submit">Speichern</button>
</div>
</section>
</form>
<script>
function syncEncryptionFields() {
const mode = document.getElementById('encryption_mode').value;
const box = document.getElementById('password_box');
box.style.display = mode === 'password-aes256' ? 'block' : 'none';
let currentStep = 0;
function htmlEscape(v) { return String(v).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;'); }
function showStep(i) {
document.querySelectorAll('.wizard-step').forEach((el, idx) => el.classList.toggle('active', idx === i));
document.querySelectorAll('[data-step-pill]').forEach((el, idx) => el.classList.toggle('active', idx === i));
currentStep = i;
}
function requireValue(id, msg) { const el = document.getElementById(id); if (!el || !el.value.trim()) { alert(msg); el?.focus(); return false; } return true; }
function validateStep(i) {
if (i === 0) return requireValue('name', 'Bitte Backup Name setzen.');
if (i === 1) {
const p = document.getElementById('source_protocol').value;
if (p === 'LOCAL') return requireValue('source_local_path', 'Bitte lokalen Source-Pfad angeben.');
if (!requireValue('source_host','Bitte Host/IP setzen.')) return false;
if (!requireValue('source_username','Bitte Benutzer setzen.')) return false;
if (!requireValue('source_password','Bitte Passwort setzen.')) return false;
if (p === 'SMB' && !requireValue('source_share','Bitte Share setzen.')) return false;
return requireValue('source_subpath','Bitte Pfad setzen.');
}
if (i === 2) {
const p = document.getElementById('destination_protocol').value;
if (p === 'LOCAL') return requireValue('destination_local_path', 'Bitte lokalen Destination-Pfad angeben.');
if (!requireValue('destination_host','Bitte Host/IP setzen.')) return false;
if (!requireValue('destination_username','Bitte Benutzer setzen.')) return false;
if (!requireValue('destination_password','Bitte Passwort setzen.')) return false;
if (p === 'SMB' && !requireValue('destination_share','Bitte Share setzen.')) return false;
return requireValue('destination_subpath','Bitte Pfad setzen.');
}
if (i === 3) {
if (!requireValue('target_pattern','Bitte Ziel setzen.')) return false;
if (document.getElementById('encryption_mode').value === 'password-aes256') {
if (!requireValue('archive_password','Bitte Passwort setzen.')) return false;
if (!requireValue('archive_password_confirm','Bitte Passwort bestätigen.')) return false;
}
return true;
}
return true;
}
function nextStep(i) { if (!validateStep(i)) return; showStep(Math.min(i+1,4)); }
function prevStep(i) { showStep(Math.max(i-1,0)); }
function setRequired(el, on) { if (!el) return; on ? el.setAttribute('required','required') : el.removeAttribute('required'); }
function syncProtocolFields(prefix) {
const protocol = document.getElementById(prefix+'_protocol').value;
const remoteFields = document.getElementById(prefix+'_remote_fields');
const localPathCol = document.getElementById(prefix+'_local_path_col');
const hostCol = document.getElementById(prefix+'_host_col');
const shareRow = document.getElementById(prefix+'_share_row');
const host = document.getElementById(prefix+'_host');
const share = document.getElementById(prefix+'_share');
const subpath = document.getElementById(prefix+'_subpath');
const user = document.getElementById(prefix+'_username');
const pass = document.getElementById(prefix+'_password');
const localPath = document.getElementById(prefix+'_local_path');
if (protocol === 'LOCAL') {
remoteFields.style.display='none'; localPathCol.style.display='block';
hostCol.style.display='none'; shareRow.style.display='none';
const portUiL = document.getElementById(prefix+'_port_ui');
if (portUiL) { portUiL.style.display='none'; portUiL.value=''; }
setRequired(host,false); setRequired(share,false); setRequired(user,false); setRequired(pass,false); setRequired(localPath,true);
subpath.value = localPath.value||''; share.value=''; user.value=''; pass.value=''; host.value='local';
if (prefix==='source') setSourceEntryType('directory');
return;
}
remoteFields.style.display='block'; localPathCol.style.display='none';
hostCol.style.display='block'; shareRow.style.display = protocol==='SMB'?'block':'none';
setRequired(host,true); setRequired(share,protocol==='SMB'); setRequired(user,true); setRequired(pass,true); setRequired(localPath,false);
const portUi = document.getElementById(prefix+'_port_ui');
if (portUi) {
if (protocol==='FTP') { portUi.style.display='block'; if(!portUi.value) portUi.value='21'; }
else if (protocol==='SFTP') { portUi.style.display='block'; if(!portUi.value) portUi.value='22'; }
else { portUi.style.display='none'; portUi.value=''; }
}
if (protocol==='FTP'||protocol==='SFTP') { if(!share.value) share.value='/'; if(!subpath.value) subpath.value='/'; }
}
function syncScheduleFields() {
const mode = document.getElementById('schedule_mode').value;
document.getElementById('schedule_weekday_box').style.display = mode === 'weekly' || mode === 'every_n_weeks' ? 'block' : 'none';
document.getElementById('schedule_dom_box').style.display = mode === 'monthly' || mode === 'yearly' ? 'block' : 'none';
document.getElementById('schedule_interval_box').style.display = mode === 'every_n_days' || mode === 'every_n_weeks' ? 'block' : 'none';
document.getElementById('schedule_cron_box').style.display = mode === 'custom' ? 'block' : 'none';
function collectPayload(prefix) {
const protocol = document.getElementById(prefix+'_protocol').value;
const payload = { prefix, [prefix+'_protocol']: protocol,
[prefix+'_host']: document.getElementById(prefix+'_host').value,
[prefix+'_share']: document.getElementById(prefix+'_share').value,
[prefix+'_subpath']: document.getElementById(prefix+'_subpath').value,
[prefix+'_username']: document.getElementById(prefix+'_username').value,
[prefix+'_password']: document.getElementById(prefix+'_password').value };
if (protocol==='LOCAL') {
payload[prefix+'_host']='local'; payload[prefix+'_share']=''; payload[prefix+'_username']=''; payload[prefix+'_password']='';
payload[prefix+'_subpath'] = document.getElementById(prefix+'_local_path').value;
document.getElementById(prefix+'_subpath').value = payload[prefix+'_subpath'];
}
if (protocol==='FTP'||protocol==='SFTP') {
const pUi=document.getElementById(prefix+'_port_ui');
const pVal=pUi?.value||(protocol==='FTP'?'21':'22');
payload[prefix+'_host']=String(payload[prefix+'_host']||'').split(':')[0]+':'+pVal;
}
return payload;
}
document.getElementById('encryption_mode').addEventListener('change', syncEncryptionFields);
document.getElementById('schedule_mode').addEventListener('change', syncScheduleFields);
syncEncryptionFields();
syncScheduleFields();
function setSourceEntryType(t) {
const type = t==='file'?'file':'directory';
document.getElementById('source_entry_type').value = type;
document.getElementById('target_kind').value = type==='file'?'file':'folder';
}
async function loadLocalBrowserLevel(prefix, path) {
const list = document.getElementById(prefix+'_local_browser_tree');
const meta = document.getElementById(prefix+'_local_browser_meta');
list.innerHTML='<li class="tree-empty">L\u00e4dt\u2026</li>';
let data;
try { const r=await fetch('{{ url_for("api_local_browse") }}?path='+encodeURIComponent(path)); data=await r.json(); }
catch(e) { list.innerHTML='<li class="tree-empty">Verbindungsfehler</li>'; return; }
if (!data.ok) { meta.textContent=''; list.innerHTML='<li class="tree-empty">'+htmlEscape(data.message)+'</li>'; return; }
meta.textContent='Pfad: '+data.path;
document.getElementById(prefix+'_local_path').value=data.path;
document.getElementById(prefix+'_subpath').value=data.path;
list.innerHTML='';
const allEntries=data.entries||[];
const entries=filterEntries(allEntries, prefix+'_local_show_hidden');
if (!entries.length) { const e=document.createElement('li'); e.className='tree-empty'; e.textContent='Keine Eintr\u00e4ge'; list.appendChild(e); return; }
entries.forEach(entry=>{
const li=document.createElement('li'); const btn=document.createElement('button');
btn.type='button'; btn.className='tree-node';
btn.textContent=(entry.entry_type==='up'?'\u21a9':entry.entry_type==='file'?'\ud83d\udcc4':'\ud83d\udcc1')+' '+entry.name;
btn.addEventListener('click',()=>{
if (entry.entry_type==='file') {
document.getElementById(prefix+'_local_path').value=entry.path;
document.getElementById(prefix+'_subpath').value=entry.path;
if(prefix==='source') setSourceEntryType('file');
list.querySelectorAll('.tree-node').forEach(n=>n.classList.remove('active')); btn.classList.add('active');
meta.textContent='Pfad: '+entry.path;
} else {
document.getElementById(prefix+'_local_path').value=entry.path;
document.getElementById(prefix+'_subpath').value=entry.path;
if(prefix==='source') setSourceEntryType('directory');
loadLocalBrowserLevel(prefix,entry.path);
}
});
li.appendChild(btn); list.appendChild(li);
});
}
function openLocalBrowserOverlay(prefix) {
document.getElementById(prefix+'_local_browser_overlay').style.display='flex';
loadLocalBrowserLevel(prefix, document.getElementById(prefix+'_local_path').value||'/');
}
function closeLocalBrowserOverlay(prefix) { document.getElementById(prefix+'_local_browser_overlay').style.display='none'; }
function acceptLocalBrowserSelection(prefix) { closeLocalBrowserOverlay(prefix); }
function refreshLocalBrowserLevel(prefix) {
loadLocalBrowserLevel(prefix, document.getElementById(prefix+'_local_path').value||'/');
}
function openLocalCreateFolderOverlay() {
document.getElementById('destination_local_new_folder_btn').style.display='none';
const bar=document.getElementById('destination_local_new_folder_bar');
bar.style.display='flex';
const i=document.getElementById('destination_local_new_folder'); i.value=''; i.focus();
}
function closeLocalCreateFolderOverlay() {
document.getElementById('destination_local_new_folder_bar').style.display='none';
document.getElementById('destination_local_new_folder_btn').style.display='';
}
async function createLocalDestinationFolder() {
const inp=document.getElementById('destination_local_new_folder'); const name=inp.value.trim();
if (!name) return;
const currentPath=document.getElementById('destination_local_path').value||'/';
const r=await fetch('{{ url_for("api_local_mkdir") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:currentPath,folder_name:name})});
const data=await r.json();
if (!data.ok) { const m=document.getElementById('destination_local_browser_meta'); m.textContent=data.message||'Fehler.'; m.style.color='#b9382f'; return; }
inp.value=''; document.getElementById('destination_local_path').value=data.path; document.getElementById('destination_subpath').value=data.path;
closeLocalCreateFolderOverlay(); loadLocalBrowserLevel('destination',data.path);
}
async function loadBrowserLevel(prefix, path) {
const list = document.getElementById(prefix+'_browser_tree');
const meta = document.getElementById(prefix+'_browser_meta');
list.innerHTML = '<li class="tree-empty">Lädt…</li>';
const payload = collectPayload(prefix); payload[prefix+'_subpath'] = path;
let data;
try { const r = await fetch('{{ url_for("api_target_browse") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); data=await r.json(); }
catch(e) { list.innerHTML='<li class="tree-empty">Verbindungsfehler</li>'; return; }
const msg = document.getElementById(prefix+'_test_message');
if (!data.ok) { msg.textContent=data.message||'Fehler.'; msg.style.color='#b9382f'; list.innerHTML=''; return; }
msg.textContent=''; document.getElementById(prefix+'_subpath').value=data.path; meta.textContent='Pfad: '+data.path;
list.innerHTML='';
const allEntries2=data.entries||[];
const entries=filterEntries(allEntries2, prefix+'_local_show_hidden');
if (!entries.length) { const e=document.createElement('li'); e.className='tree-empty'; e.textContent='Keine Einträge'; list.appendChild(e); return; }
entries.forEach(entry=>{
const li=document.createElement('li'); const btn=document.createElement('button');
btn.type='button'; btn.className='tree-node';
btn.textContent=(entry.entry_type==='up'?'↩':entry.entry_type==='file'?'📄':'📁')+' '+entry.name;
btn.addEventListener('click',()=>{
if (entry.entry_type==='file') {
document.getElementById(prefix+'_subpath').value=entry.path;
if (prefix==='source') setSourceEntryType('file');
list.querySelectorAll('.tree-node').forEach(n=>n.classList.remove('active')); btn.classList.add('active');
document.getElementById(prefix+'_test_message').textContent='Datei ausgewählt: '+entry.path;
document.getElementById(prefix+'_test_message').style.color='#0a7f68';
} else { if(prefix==='source') setSourceEntryType('directory'); document.getElementById(prefix+'_subpath').value=entry.path; loadBrowserLevel(prefix,entry.path); }
});
li.appendChild(btn); list.appendChild(li);
});
}
function openBrowserOverlay(prefix) {
const p=document.getElementById(prefix+'_protocol').value;
if (!['SMB','FTP','SFTP'].includes(p)) return;
document.getElementById(prefix+'_browser_overlay').style.display='flex';
loadBrowserLevel(prefix,document.getElementById(prefix+'_subpath').value||'/');
}
function closeBrowserOverlay(prefix) { document.getElementById(prefix+'_browser_overlay').style.display='none'; }
function acceptBrowserSelection(prefix) { closeBrowserOverlay(prefix); }
async function browseSmb(prefix) {
const p=document.getElementById(prefix+'_protocol').value;
if (!['SMB','FTP','SFTP'].includes(p)) {
const msg=document.getElementById(prefix+'_test_message');
msg.textContent='Browser ist nur bei SMB, FTP und SFTP verfügbar.';
msg.style.color='#b9382f'; return;
}
openBrowserOverlay(prefix);
}
function refreshBrowserLevel(prefix) { loadBrowserLevel(prefix, document.getElementById(prefix+'_subpath').value||'/'); }
function filterEntries(entries, cbId) {
if (document.getElementById(cbId)?.checked) return entries;
return entries.filter(e => e.entry_type==='up' || !e.name.startsWith('.'));
}
function openCreateFolderOverlay() {
document.getElementById('destination_new_folder_btn').style.display='none';
const bar=document.getElementById('destination_new_folder_bar');
bar.style.display='flex';
const i=document.getElementById('destination_new_folder'); i.value=''; i.focus();
}
function closeCreateFolderOverlay() {
document.getElementById('destination_new_folder_bar').style.display='none';
document.getElementById('destination_new_folder_btn').style.display='';
}
async function createDestinationFolder() {
const inp=document.getElementById('destination_new_folder'); const name=inp.value.trim();
const msg=document.getElementById('destination_test_message');
if (!name) { msg.textContent='Bitte Ordnernamen eingeben.'; msg.style.color='#b9382f'; return; }
const payload=collectPayload('destination'); payload.folder_name=name;
const r=await fetch('{{ url_for("api_target_smb_mkdir") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const data=await r.json();
if (!data.ok) { msg.textContent=data.message||'Fehler.'; msg.style.color='#b9382f'; return; }
inp.value=''; document.getElementById('destination_subpath').value=data.path;
msg.textContent='Ordner erstellt: '+data.path; msg.style.color='#0a7f68';
closeCreateFolderOverlay(); loadBrowserLevel('destination',data.path);
}
function syncCompressionFields() {
const c=document.getElementById('compression_method').value;
const encRow=document.getElementById('encryption_row');
const encSel=document.getElementById('encryption_mode');
const pwdCol=document.getElementById('archive_pwd_col');
const pwd2Col=document.getElementById('archive_pwd2_col');
const pwd=document.getElementById('archive_password'); const pwd2=document.getElementById('archive_password_confirm');
if (c==='none') { encRow.style.display='none'; encSel.value='none'; setRequired(pwd,false); setRequired(pwd2,false); pwd.value=''; pwd2.value=''; return; }
encRow.style.display='';
const showPwd=encSel.value==='password-aes256';
pwdCol.style.display=showPwd?'':'none'; pwd2Col.style.display=showPwd?'':'none';
setRequired(pwd,showPwd); setRequired(pwd2,showPwd);
if (!showPwd) { pwd.value=''; pwd2.value=''; }
}
function syncBackupMode() {
const mode=document.getElementById('backup_mode').value;
const compCol=document.getElementById('compression_col');
const compSel=document.getElementById('compression_method');
const rotCol=document.getElementById('rotation_col');
const targetBox=document.getElementById('target_pattern_box');
const targetInput=document.getElementById('target_pattern');
if (mode==='sync'||mode==='clone') {
compSel.value='none'; compSel.disabled=true;
compCol.style.opacity='0.45';
compCol.title='Sync/Clone arbeitet immer ohne Kompression';
rotCol.style.display='none';
targetBox.style.display='none';
targetInput.removeAttribute('required');
} else {
compSel.disabled=false; compCol.style.opacity=''; compCol.title='';
rotCol.style.display='';
targetBox.style.display='';
targetInput.setAttribute('required', '');
}
syncCompressionFields();
}
const TOKEN_GROUPS = [
{label:'Datum',defaultToken:'{date}',formats:[{token:'{date}',label:'YYYY-MM-DD',sub:'2026-07-30'},{token:'{date_de}',label:'TT.MM.JJJJ',sub:'30.07.2026'},{token:'{date_compact}',label:'JJJJMMTT',sub:'20260730'}]},
{label:'Uhrzeit',defaultToken:'{time}',formats:[{token:'{time}',label:'HH-MM-SS',sub:'14-30-00'},{token:'{time_short}',label:'HHMM',sub:'1430'}]},
{label:'Datum+Zeit',defaultToken:'{datetime}',formats:[{token:'{datetime}',label:'JJJJ-MM-TT_HH-MM-SS',sub:'2026-07-30_14-30-00'}]},
];
const activeTokens = TOKEN_GROUPS.map(g=>g.defaultToken);
function buildTokenBar() {
const bar=document.getElementById('token_bar'); bar.innerHTML='';
TOKEN_GROUPS.forEach((group,idx)=>{
const chip=document.createElement('span'); chip.className='token-chip'; chip.draggable=true;
chip.textContent=group.label; chip.title=activeTokens[idx]+' · Rechtsklick für Formate';
chip.addEventListener('click',()=>insertToken(activeTokens[idx]));
chip.addEventListener('dragstart',e=>{e.dataTransfer.setData('text/plain',activeTokens[idx]);e.dataTransfer.effectAllowed='copy';});
chip.addEventListener('contextmenu',e=>{e.preventDefault();showTokenMenu(e,group,idx);});
bar.appendChild(chip);
});
}
function insertToken(token) {
const inp=document.getElementById('target_pattern'); const s=inp.selectionStart??inp.value.length; const e=inp.selectionEnd??inp.value.length;
inp.value=inp.value.slice(0,s)+token+inp.value.slice(e); inp.setSelectionRange(s+token.length,s+token.length); inp.focus();
}
function showTokenMenu(ev,group,idx) {
const menu=document.getElementById('token_context_menu'); menu.innerHTML='';
group.formats.forEach(fmt=>{
const item=document.createElement('div'); item.className='token-menu-item';
item.innerHTML='<div>'+htmlEscape(fmt.label)+'</div><div class="token-menu-sub">'+htmlEscape(fmt.token)+' &nbsp;·&nbsp; '+htmlEscape(fmt.sub)+'</div>';
item.addEventListener('click',()=>{activeTokens[idx]=fmt.token;buildTokenBar();closeTokenMenu();insertToken(fmt.token);});
menu.appendChild(item);
});
menu.style.left=ev.clientX+'px'; menu.style.top=ev.clientY+'px'; menu.style.display='block';
requestAnimationFrame(()=>{ const r=menu.getBoundingClientRect(); if(r.right>window.innerWidth) menu.style.left=(ev.clientX-r.width)+'px'; if(r.bottom>window.innerHeight) menu.style.top=(ev.clientY-r.height)+'px'; });
}
function closeTokenMenu() { document.getElementById('token_context_menu').style.display='none'; }
document.addEventListener('click',e=>{ if(!e.target.closest('.token-chip')&&!e.target.closest('.token-menu')) closeTokenMenu(); });
const _tp=document.getElementById('target_pattern');
_tp.addEventListener('dragover',e=>e.preventDefault());
_tp.addEventListener('drop',e=>{ e.preventDefault(); const t=e.dataTransfer.getData('text/plain'); if(!t) return; const s=_tp.selectionStart??_tp.value.length; const en=_tp.selectionEnd??_tp.value.length; _tp.value=_tp.value.slice(0,s)+t+_tp.value.slice(en); _tp.setSelectionRange(s+t.length,s+t.length); _tp.focus(); });
document.getElementById('destination_new_folder').addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();createDestinationFolder();} if(e.key==='Escape') closeCreateFolderOverlay(); });
document.getElementById('name').addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();nextStep(0);} });
let _cronTimer=null;
async function updateCronPreview() {
const val=document.getElementById('schedule_cron').value.trim();
const preview=document.getElementById('cron_preview'); const errEl=document.getElementById('cron_error');
if (!val) { preview.style.display='none'; errEl.style.display='none'; return; }
try {
const r=await fetch('{{ url_for("api_cron_preview") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cron:val})});
const data=await r.json();
if (!data.ok) { preview.style.display='none'; errEl.textContent=data.message||'Ungültiger Ausdruck.'; errEl.style.display='block'; return; }
document.getElementById('cron_description').textContent=data.description;
document.getElementById('cron_next_run').textContent='Nächstes Backup: '+data.next_run;
preview.style.display='block'; errEl.style.display='none';
} catch(_) { preview.style.display='none'; }
}
document.getElementById('source_protocol').addEventListener('change',()=>syncProtocolFields('source'));
document.getElementById('destination_protocol').addEventListener('change',()=>syncProtocolFields('destination'));
document.getElementById('source_local_path').addEventListener('input',ev=>{ document.getElementById('source_subpath').value=ev.target.value; });
document.getElementById('destination_local_path').addEventListener('input',ev=>{ document.getElementById('destination_subpath').value=ev.target.value; });
document.getElementById('destination_local_new_folder').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){e.preventDefault();createLocalDestinationFolder();} if(e.key==='Escape') closeLocalCreateFolderOverlay(); });
document.getElementById('compression_method').addEventListener('change',syncCompressionFields);
document.getElementById('encryption_mode').addEventListener('change',syncCompressionFields);
document.getElementById('schedule_cron').addEventListener('input',()=>{ clearTimeout(_cronTimer); _cronTimer=setTimeout(updateCronPreview,500); });
// Preserve pre-filled passwords: syncProtocolFields may clear pass for LOCAL,
// but for remote protocols the pre-filled "***" value stays untouched.
syncProtocolFields('source');
syncProtocolFields('destination');
// syncBackupMode calls syncCompressionFields internally
syncBackupMode();
buildTokenBar();
if (document.getElementById('schedule_cron').value.trim()) updateCronPreview();
showStep(0);
// Merge port into host field before form submission (FTP/SFTP)
document.getElementById('backupForm').addEventListener('submit', () => {
for (const prefix of ['source', 'destination']) {
const protocol = document.getElementById(prefix + '_protocol').value;
if (protocol === 'FTP' || protocol === 'SFTP') {
const hostEl = document.getElementById(prefix + '_host');
const portUi = document.getElementById(prefix + '_port_ui');
if (portUi?.value && hostEl) {
hostEl.value = hostEl.value.split(':')[0] + ':' + portUi.value;
}
}
}
});
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -101,7 +101,21 @@
cursor: pointer;
}
.messages { display: grid; gap: 10px; }
.flash-overlay {
position: fixed;
top: 18px;
right: 18px;
z-index: 1200;
display: grid;
gap: 10px;
max-width: min(92vw, 360px);
pointer-events: none;
}
.flash-overlay.hidden {
opacity: 0;
transition: opacity 220ms ease;
}
.msg {
margin: 0;
@@ -109,6 +123,8 @@
border-radius: 12px;
font-weight: 600;
font-size: 14px;
box-shadow: 0 10px 24px rgba(30, 42, 59, 0.16);
pointer-events: auto;
}
.msg.success {
@@ -240,11 +256,18 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section class="panel content messages">
<div id="flash-overlay" class="flash-overlay" role="status" aria-live="polite">
{% for category, message in messages %}
<p class="msg {{ category }}">{{ message }}</p>
{% endfor %}
</section>
</div>
<script>
window.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('flash-overlay');
if (!overlay) return;
window.setTimeout(() => overlay.classList.add('hidden'), 3000);
});
</script>
{% endif %}
{% endwith %}
+269 -6
View File
@@ -8,6 +8,12 @@
white-space: nowrap;
}
.run-action {
position: relative;
display: inline-flex;
margin-right: 5px;
}
.icon-btn {
display: inline-flex;
align-items: center;
@@ -34,6 +40,72 @@
color: #b9382f;
border-color: rgba(185, 56, 47, 0.3);
}
.icon-btn-stop {
color: #b9382f;
border-color: rgba(185, 56, 47, 0.38);
background: #fff2f1;
}
.run-tooltip {
position: absolute;
right: 0;
bottom: 100%;
/* extra bottom-padding bridges the gap so CSS :hover stays active while moving mouse to tooltip */
padding: 9px 10px 18px;
transform: translateY(4px);
width: 280px;
border: 1px solid rgba(30, 42, 59, 0.2);
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 28px rgba(30, 42, 59, 0.2);
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease, transform 150ms ease;
pointer-events: auto;
user-select: text;
cursor: text;
z-index: 30;
}
.run-action:hover .run-tooltip,
.run-action:focus-within .run-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.run-tooltip-title {
margin: 0 0 6px;
font-size: 12px;
font-weight: 700;
color: #1e2a3b;
}
.run-progress {
width: 100%;
height: 7px;
border-radius: 999px;
background: #ecf2fa;
overflow: hidden;
margin-bottom: 6px;
}
.run-progress > span {
display: block;
width: 0;
height: 100%;
background: linear-gradient(90deg, #0a7f68, #02a883);
transition: width 220ms ease;
}
.run-tooltip-text {
margin: 0;
font-size: 12px;
color: #5a6e87;
line-height: 1.4;
word-break: break-word;
}
</style>
<h2>Eingerichtete Backups</h2>
@@ -49,6 +121,7 @@
<tr>
<th>Name</th>
<th>Zeitplan</th>
<th>Nächster Lauf</th>
<th>Letzter Lauf</th>
<th>Größe</th>
<th>Aktionen</th>
@@ -58,13 +131,28 @@
{% for backup in backups %}
<tr>
<td><strong>{{ backup.name }}</strong></td>
<td>{{ backup.schedule_mode }}</td>
<td>{{ backup.last_run_at | fmt_dt }}</td>
<td>{{ backup.last_size_bytes | fmt_bytes }}</td>
<td>{{ backup.schedule_cron or '–' }}</td>
<td data-next-run>{% if backup.schedule_cron %}{{ backup.next_run_at | fmt_dt }}{% else %}Kein automatisches Backup{% endif %}</td>
<td data-last-run>{{ backup.last_run_at | fmt_dt }}</td>
<td data-last-size>{{ backup.last_size_bytes | fmt_bytes }}</td>
<td class="actions">
<form method="post" action="{{ url_for('backup_run', backup_id=backup.backup_id) }}" style="display:inline;">
<button class="icon-btn" type="submit" title="Manuell ausführen" aria-label="Manuell ausführen">&#9654;</button>
</form>
<span class="run-action" data-run-action data-backup-id="{{ backup.backup_id }}">
<button
class="icon-btn"
type="button"
title="Manuell ausführen"
aria-label="Manuell ausführen"
data-run-toggle
data-run-url="{{ url_for('backup_run_api', backup_id=backup.backup_id) }}"
data-stop-url="{{ url_for('backup_stop_api', backup_id=backup.backup_id) }}"
data-status-url="{{ url_for('backup_status_api', backup_id=backup.backup_id) }}"
>&#9654;</button>
<div class="run-tooltip" role="status" aria-live="polite">
<p class="run-tooltip-title" data-run-title>Bereit</p>
<div class="run-progress"><span data-run-bar></span></div>
<p class="run-tooltip-text" data-run-text>Kein Lauf aktiv</p>
</div>
</span>
<a class="icon-btn" href="{{ url_for('backup_edit', backup_id=backup.backup_id) }}" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</a>
<form method="post" action="{{ url_for('backup_delete', backup_id=backup.backup_id) }}" style="display:inline;" onsubmit="return confirm('Backup wirklich löschen?');">
<button class="icon-btn icon-btn-danger" type="submit" title="Löschen" aria-label="Löschen">&#128465;</button>
@@ -79,4 +167,179 @@
<p>Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.</p>
</div>
{% endif %}
<script>
(() => {
const fastPollMs = 1200;
const idlePollMs = 10000;
const rows = Array.from(document.querySelectorAll("[data-run-action]"));
if (!rows.length) {
return;
}
let pollTimer = null;
let pollInFlight = false;
const clampPercent = (value) => {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
return Math.max(0, Math.min(100, Math.round(num)));
};
const formatBytes = (value) => {
if (value === null || value === undefined || value === "") {
return "Noch kein Lauf";
}
const num = Number(value);
if (!Number.isFinite(num)) {
return "Unbekannt";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = num;
let unitIdx = 0;
while (size >= 1024 && unitIdx < units.length - 1) {
size /= 1024;
unitIdx += 1;
}
return `${size.toFixed(2)} ${units[unitIdx]}`;
};
const updateRuntimeCells = (container, runtime) => {
if (!runtime) {
return;
}
const row = container.closest("tr");
if (!row) {
return;
}
const lastRunCell = row.querySelector("[data-last-run]");
const lastSizeCell = row.querySelector("[data-last-size]");
const nextRunCell = row.querySelector("[data-next-run]");
if (lastRunCell) {
lastRunCell.textContent = runtime.last_run_at ? String(runtime.last_run_at) : "Noch kein Lauf";
}
if (lastSizeCell) {
lastSizeCell.textContent = formatBytes(runtime.last_size_bytes);
}
if (nextRunCell && runtime.next_run_at !== undefined) {
nextRunCell.textContent = runtime.next_run_at || "Kein automatisches Backup";
}
};
const renderStatus = (container, status) => {
const button = container.querySelector("[data-run-toggle]");
const title = container.querySelector("[data-run-title]");
const text = container.querySelector("[data-run-text]");
const bar = container.querySelector("[data-run-bar]");
const isRunning = !!status?.is_running;
const percent = clampPercent(status?.progress_percent ?? 0);
const progressText = String(status?.progress_text || (isRunning ? "In Progress" : "Bereit"));
const statusMessage = String(status?.status_message || "");
button.dataset.isRunning = isRunning ? "1" : "0";
button.innerHTML = isRunning ? "&#10006;" : "&#9654;";
button.classList.toggle("icon-btn-stop", isRunning);
button.title = isRunning ? "Abbrechen" : "Manuell ausführen";
button.setAttribute("aria-label", isRunning ? "Backup-Lauf abbrechen" : "Manuell ausführen");
title.textContent = `${progressText} (${percent}%)`;
text.textContent = statusMessage || (isRunning ? "Backup läuft..." : "Kein Lauf aktiv");
bar.style.width = `${percent}%`;
};
const fetchStatus = async (container) => {
const button = container.querySelector("[data-run-toggle]");
try {
const response = await fetch(button.dataset.statusUrl, {
method: "GET",
headers: { "Accept": "application/json" },
credentials: "same-origin"
});
if (!response.ok) {
return button.dataset.isRunning === "1";
}
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("application/json")) {
return button.dataset.isRunning === "1";
}
const data = await response.json().catch(() => null);
if (!data || !data.status) {
return button.dataset.isRunning === "1";
}
renderStatus(container, data.status || {});
updateRuntimeCells(container, data.runtime || null);
return !!data.status?.is_running;
} catch (error) {
console.warn("Status-Polling fehlgeschlagen", error);
return button.dataset.isRunning === "1";
}
};
const schedulePoll = (delayMs) => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
pollTimer = window.setTimeout(() => {
void pollStatuses();
}, delayMs);
};
const pollStatuses = async () => {
if (pollInFlight) {
schedulePoll(fastPollMs);
return;
}
pollInFlight = true;
try {
const runningStates = await Promise.all(rows.map((container) => fetchStatus(container)));
const anyRunning = runningStates.some((isRunning) => isRunning === true);
schedulePoll(anyRunning ? fastPollMs : idlePollMs);
} finally {
pollInFlight = false;
}
};
const toggleRun = async (container) => {
const button = container.querySelector("[data-run-toggle]");
const isRunning = button.dataset.isRunning === "1";
const endpoint = isRunning ? button.dataset.stopUrl : button.dataset.runUrl;
button.disabled = true;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"X-Requested-With": "fetch"
},
credentials: "same-origin"
});
const data = await response.json().catch(() => ({}));
if (data.status) {
renderStatus(container, data.status);
}
updateRuntimeCells(container, data.runtime || null);
schedulePoll(fastPollMs);
if (!response.ok && data.message) {
console.warn(data.message);
}
} catch (error) {
console.warn("Run-Aktion fehlgeschlagen", error);
} finally {
button.disabled = false;
}
};
rows.forEach((container) => {
const button = container.querySelector("[data-run-toggle]");
button.addEventListener("click", () => {
void toggleRun(container);
});
void fetchStatus(container);
});
void pollStatuses();
})();
</script>
{% endblock %}