v0.0.1 first commit

This commit is contained in:
2026-07-29 17:36:22 +02:00
parent 49bb6815a0
commit 2eee55384e
28 changed files with 3902 additions and 1 deletions
+31
View File
@@ -0,0 +1,31 @@
{% extends "base_admin.html" %}
{% block title %}StFV Backup - Admin Konto{% endblock %}
{% block content %}
<h2>Admin Konto</h2>
<p>Hier kannst du deinen Benutzernamen und dein Passwort komplett aendern.</p>
<div class="card">
<form method="post" action="{{ url_for('account_settings') }}">
<label for="new_username">Neuer Benutzername</label>
<input id="new_username" name="new_username" type="text" value="{{ adminuser }}" required>
<label for="current_password">Aktuelles Passwort</label>
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
<div class="row">
<div>
<label for="new_password">Neues Passwort</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password" required>
</div>
<div>
<label for="confirm_password">Neues Passwort bestaetigen</label>
<input id="confirm_password" name="confirm_password" type="password" autocomplete="new-password" required>
</div>
</div>
<button class="btn" type="submit">Admin Konto aktualisieren</button>
</form>
</div>
{% endblock %}
+109
View File
@@ -0,0 +1,109 @@
{% extends "base_admin.html" %}
{% 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>
<form method="post" action="{{ url_for('backup_edit', backup_id=backup.id) }}">
<div class="card">
<label for="name">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>
</div>
<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' }}">
</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>
<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>
<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>
<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>
</div>
<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>
</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';
}
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';
}
document.getElementById('encryption_mode').addEventListener('change', syncEncryptionFields);
document.getElementById('schedule_mode').addEventListener('change', syncScheduleFields);
syncEncryptionFields();
syncScheduleFields();
</script>
{% endblock %}
+714
View File
@@ -0,0 +1,714 @@
{% extends "base_admin.html" %}
{% block title %}StFV Backup - Neues Backup{% endblock %}
{% block content %}
<h2>Neues Backup einrichten</h2>
<p>Die Konfiguration erscheint Schritt fuer Schritt, damit du jedes Fenster nacheinander ausfuellen kannst.</p>
<style>
.wizard-step {
display: none;
}
.wizard-step.active {
display: block;
animation: fade-slide 240ms ease;
}
.wizard-nav {
margin-top: 10px;
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;
}
.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;
}
.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;
}
.mini-input {
margin-top: 8px;
display: flex;
gap: 8px;
}
.mini-input input {
margin: 0;
}
.inline-help {
color: #5a6e87;
font-size: 13px;
}
@keyframes fade-slide {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<form method="post" action="{{ url_for('backup_new') }}" id="backupForm">
<input type="hidden" id="source_entry_type" name="source_entry_type" value="directory">
<input type="hidden" id="target_kind" name="target_kind" value="folder">
<div class="wizard-nav" id="wizardNav">
<span class="wizard-pill active" data-step-pill="0">Allgemein</span>
<span class="wizard-pill" data-step-pill="1">Source</span>
<span class="wizard-pill" data-step-pill="2">Destination</span>
<span class="wizard-pill" data-step-pill="3">Target</span>
<span class="wizard-pill" data-step-pill="4">Zeitplan</span>
</div>
<section class="card wizard-step active" data-step="0">
<h3>Allgemein</h3>
<label for="name">Backup Name</label>
<input id="name" name="name" type="text" placeholder="z. B. unraid-nextcloud-daily" required>
<div class="step-actions">
<button type="button" class="btn" onclick="nextStep(0)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="1">
<h3>Source</h3>
<label for="source_protocol">Typ</label>
<select id="source_protocol" name="source_protocol" required>
{% for protocol in target_protocols %}
<option value="{{ protocol }}">{{ protocol }}</option>
{% endfor %}
</select>
<div id="source_remote_fields">
<label for="source_host">Host/IP (optional mit :Port)</label>
<input id="source_host" name="source_host" type="text" placeholder="192.168.1.20 oder 192.168.1.20:445">
<div class="row">
<div>
<label for="source_username">Benutzer</label>
<input id="source_username" name="source_username" type="text">
</div>
<div>
<label for="source_password">Passwort</label>
<input id="source_password" name="source_password" type="password">
</div>
</div>
<div id="source_share_row">
<label for="source_share">Share / Root</label>
<input id="source_share" name="source_share" type="text" placeholder="source-share">
</div>
<label for="source_subpath">Unterpfad (SMB Browser)</label>
<input id="source_subpath" name="source_subpath" type="text" placeholder="/daten/projektA" value="/">
<p id="source_test_message"></p>
<div id="source_browser" class="card" style="display:none; margin-top:10px;">
<div id="source_browser_meta" class="tree-meta"></div>
<ul id="source_browser_tree" class="tree"></ul>
</div>
<p class="inline-help">Ordnerauswahl setzt Source als Ordner, Dateiauswahl setzt Source als Datei.</p>
</div>
<div id="source_local_fields" style="display:none;">
<label for="source_local_path">Lokaler Pfad</label>
<input id="source_local_path" type="text" placeholder="/mnt/storage/quellordner">
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(1)">Zurueck</button>
<button type="button" class="btn" onclick="nextStep(1)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="2">
<h3>Destination</h3>
<label for="destination_protocol">Typ</label>
<select id="destination_protocol" name="destination_protocol" required>
{% for protocol in target_protocols %}
<option value="{{ protocol }}">{{ protocol }}</option>
{% endfor %}
</select>
<div id="destination_remote_fields">
<label for="destination_host">Host/IP (optional mit :Port)</label>
<input id="destination_host" name="destination_host" type="text" placeholder="192.168.1.30 oder 192.168.1.30:445">
<div class="row">
<div>
<label for="destination_username">Benutzer</label>
<input id="destination_username" name="destination_username" type="text">
</div>
<div>
<label for="destination_password">Passwort</label>
<input id="destination_password" name="destination_password" type="password">
</div>
</div>
<div id="destination_share_row">
<label for="destination_share">Share / Root</label>
<input id="destination_share" name="destination_share" type="text" placeholder="backup-share">
</div>
<label for="destination_subpath">Unterpfad (SMB Browser)</label>
<input id="destination_subpath" name="destination_subpath" type="text" placeholder="/backups" value="/">
<div class="mini-input">
<input id="destination_new_folder" type="text" placeholder="Neuer Ordnername">
<button class="btn" type="button" onclick="createDestinationFolder()">Ordner erstellen</button>
</div>
<p id="destination_test_message"></p>
<div id="destination_browser" class="card" style="display:none; margin-top:10px;">
<div id="destination_browser_meta" class="tree-meta"></div>
<ul id="destination_browser_tree" class="tree"></ul>
</div>
</div>
<div id="destination_local_fields" style="display:none;">
<label for="destination_local_path">Lokaler Pfad</label>
<input id="destination_local_path" type="text" placeholder="/mnt/storage/zielordner">
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(2)">Zurueck</button>
<button type="button" class="btn" onclick="nextStep(2)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="3">
<h3>Target</h3>
<label for="target_pattern" id="target_pattern_label">Zielordner</label>
<input id="target_pattern" name="target_pattern" type="text" placeholder="backup_{date}_{time}" required>
<p class="inline-help">Wenn in Source eine Datei gewaehlt wurde, ist dies die Zieldatei. Bei Ordnerauswahl ist dies der Zielordner.</p>
<label for="compression_method">Kompression</label>
<select id="compression_method" name="compression_method" required>
{% for method in compression_methods %}
<option value="{{ method }}">{{ method }}</option>
{% endfor %}
</select>
<label for="encryption_mode">Verschluesselung</label>
<select id="encryption_mode" name="encryption_mode" required>
{% for mode in encryption_modes %}
<option value="{{ mode }}">{{ mode }}</option>
{% endfor %}
</select>
<div id="encryption_password_box">
<div class="row">
<div>
<label for="archive_password">Passwort</label>
<input id="archive_password" name="archive_password" type="password">
</div>
<div>
<label for="archive_password_confirm">Passwort bestaetigen</label>
<input id="archive_password_confirm" name="archive_password_confirm" type="password">
</div>
</div>
<p class="inline-help">Passwoerter werden verschluesselt gespeichert.</p>
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(3)">Zurueck</button>
<button type="button" class="btn" onclick="nextStep(3)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="4">
<h3>Zeitplan</h3>
<label for="schedule_mode">Ausfuehrung</label>
<select id="schedule_mode" name="schedule_mode" required>
<option value="daily">Taeglich</option>
<option value="weekly">Woechentlich</option>
<option value="monthly">Monatlich</option>
<option value="yearly">Jaehrlich</option>
<option value="every_n_days">Alle X Tage</option>
<option value="every_n_weeks">Alle X Wochen</option>
<option value="custom">Benutzerdefiniert (CRON)</option>
</select>
<div id="schedule_time_box">
<label for="schedule_time">Uhrzeit</label>
<input id="schedule_time" name="schedule_time" type="time" value="02:00">
</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">Montag</option>
<option value="2">Dienstag</option>
<option value="3">Mittwoch</option>
<option value="4">Donnerstag</option>
<option value="5">Freitag</option>
<option value="6">Samstag</option>
<option value="0">Sonntag</option>
</select>
</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="1">
</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="1">
</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" placeholder="0 2 * * *">
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(4)">Zurueck</button>
<button class="btn" type="submit">Backup speichern</button>
</div>
</section>
</form>
<script>
let currentStep = 0;
function htmlEscape(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function showStep(stepIdx) {
const steps = document.querySelectorAll('.wizard-step');
const pills = document.querySelectorAll('[data-step-pill]');
steps.forEach((el, idx) => el.classList.toggle('active', idx === stepIdx));
pills.forEach((el, idx) => el.classList.toggle('active', idx === stepIdx));
currentStep = stepIdx;
}
function requireValue(id, message) {
const el = document.getElementById(id);
if (!el || !el.value.trim()) {
alert(message);
el?.focus();
return false;
}
return true;
}
function validateStep(stepIdx) {
if (stepIdx === 0) {
return requireValue('name', 'Bitte Backup Name setzen.');
}
if (stepIdx === 1) {
const protocol = document.getElementById('source_protocol').value;
if (protocol === 'LOCAL') {
return requireValue('source_local_path', 'Bitte lokalen Source-Pfad angeben.');
}
if (!requireValue('source_host', 'Bitte Source Host/IP setzen.')) return false;
if (!requireValue('source_username', 'Bitte Source Benutzer setzen.')) return false;
if (!requireValue('source_password', 'Bitte Source Passwort setzen.')) return false;
if (protocol === 'SMB' && !requireValue('source_share', 'Bitte Source Share setzen.')) return false;
return requireValue('source_subpath', 'Bitte Source Unterpfad setzen.');
}
if (stepIdx === 2) {
const protocol = document.getElementById('destination_protocol').value;
if (protocol === 'LOCAL') {
return requireValue('destination_local_path', 'Bitte lokalen Destination-Pfad angeben.');
}
if (!requireValue('destination_host', 'Bitte Destination Host/IP setzen.')) return false;
if (!requireValue('destination_username', 'Bitte Destination Benutzer setzen.')) return false;
if (!requireValue('destination_password', 'Bitte Destination Passwort setzen.')) return false;
if (protocol === 'SMB' && !requireValue('destination_share', 'Bitte Destination Share setzen.')) return false;
return requireValue('destination_subpath', 'Bitte Destination Unterpfad setzen.');
}
if (stepIdx === 3) {
if (!requireValue('target_pattern', 'Bitte Zielordner oder Zieldatei setzen.')) return false;
const mode = document.getElementById('encryption_mode').value;
if (mode === 'password-aes256') {
if (!requireValue('archive_password', 'Bitte Verschluesselungs-Passwort setzen.')) return false;
if (!requireValue('archive_password_confirm', 'Bitte Passwort bestaetigen.')) return false;
}
return true;
}
return true;
}
function nextStep(stepIdx) {
if (!validateStep(stepIdx)) return;
showStep(Math.min(stepIdx + 1, 4));
}
function prevStep(stepIdx) {
showStep(Math.max(stepIdx - 1, 0));
}
function setRequired(el, enabled) {
if (!el) return;
if (enabled) {
el.setAttribute('required', 'required');
} else {
el.removeAttribute('required');
}
}
function syncProtocolFields(prefix) {
const protocol = document.getElementById(prefix + '_protocol').value;
const remoteFields = document.getElementById(prefix + '_remote_fields');
const localFields = document.getElementById(prefix + '_local_fields');
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';
localFields.style.display = 'block';
shareRow.style.display = 'none';
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';
localFields.style.display = 'none';
shareRow.style.display = protocol === 'SMB' ? 'block' : 'none';
setRequired(host, true);
setRequired(share, protocol === 'SMB');
setRequired(user, true);
setRequired(pass, true);
setRequired(localPath, false);
if (protocol === 'FTP' || protocol === 'SFTP') {
if (!share.value) share.value = '/';
if (!subpath.value) subpath.value = '/';
}
}
function collectPayload(prefix) {
const protocol = document.getElementById(prefix + '_protocol').value;
const payload = {
prefix: 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'];
}
return payload;
}
function clearActiveTreeNodes(prefix) {
document
.querySelectorAll('#' + prefix + '_browser_tree .tree-node')
.forEach((node) => node.classList.remove('active'));
}
function setSourceEntryType(entryType) {
const t = entryType === 'file' ? 'file' : 'directory';
document.getElementById('source_entry_type').value = t;
const targetKind = t === 'file' ? 'file' : 'folder';
document.getElementById('target_kind').value = targetKind;
const label = document.getElementById('target_pattern_label');
label.textContent = targetKind === 'file' ? 'Zieldatei' : 'Zielordner';
}
function makeTreeItem(prefix, entry) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'tree-node';
let icon = '📁';
if (entry.entry_type === 'up') icon = '↩';
if (entry.entry_type === 'file') icon = '📄';
button.textContent = icon + ' ' + entry.name;
button.dataset.path = entry.path;
button.dataset.entryType = entry.entry_type;
const childList = document.createElement('ul');
childList.className = 'tree';
childList.style.display = 'none';
button.addEventListener('click', async () => {
clearActiveTreeNodes(prefix);
button.classList.add('active');
document.getElementById(prefix + '_subpath').value = entry.path;
if (prefix === 'source') {
setSourceEntryType(entry.entry_type === 'file' ? 'file' : 'directory');
}
if (entry.entry_type === 'file') {
document.getElementById(prefix + '_test_message').textContent = 'Datei ausgewaehlt: ' + entry.path;
document.getElementById(prefix + '_test_message').style.color = '#0a7f68';
return;
}
await loadTreeLevel(prefix, entry.path, childList);
});
li.appendChild(button);
li.appendChild(childList);
return li;
}
async function loadTreeLevel(prefix, path, targetList) {
const payload = collectPayload(prefix);
payload[prefix + '_subpath'] = path;
const response = await fetch('{{ url_for("api_target_browse_smb") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
const msg = document.getElementById(prefix + '_test_message');
if (!data.ok) {
msg.textContent = data.message || 'SMB-Browsing fehlgeschlagen.';
msg.style.color = '#b9382f';
return;
}
msg.textContent = 'SMB-Verbindung erfolgreich.';
msg.style.color = '#0a7f68';
document.getElementById(prefix + '_subpath').value = data.path;
document.getElementById(prefix + '_browser_meta').textContent = 'Aktueller Pfad: ' + data.path;
targetList.innerHTML = '';
const entries = data.entries || [];
if (!entries.length) {
const empty = document.createElement('li');
empty.className = 'tree-empty';
empty.textContent = 'Keine Eintraege';
targetList.appendChild(empty);
targetList.style.display = 'block';
return;
}
entries.forEach((entry) => {
targetList.appendChild(makeTreeItem(prefix, entry));
});
targetList.style.display = 'block';
}
async function renderRootTree(prefix) {
const rootList = document.getElementById(prefix + '_browser_tree');
rootList.innerHTML = '';
await loadTreeLevel(prefix, document.getElementById(prefix + '_subpath').value || '/', rootList);
}
async function browseSmb(prefix) {
if (document.getElementById(prefix + '_protocol').value !== 'SMB') {
const msg = document.getElementById(prefix + '_test_message');
msg.textContent = 'SMB Browser nur bei SMB aktiv.';
msg.style.color = '#b9382f';
return;
}
const container = document.getElementById(prefix + '_browser');
container.style.display = 'block';
await renderRootTree(prefix);
}
async function createDestinationFolder() {
const folderInput = document.getElementById('destination_new_folder');
const name = folderInput.value.trim();
const msg = document.getElementById('destination_test_message');
if (!name) {
msg.textContent = 'Bitte zuerst einen neuen Ordnernamen eingeben.';
msg.style.color = '#b9382f';
return;
}
const payload = collectPayload('destination');
payload.folder_name = name;
const response = await fetch('{{ url_for("api_target_smb_mkdir") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.ok) {
msg.textContent = data.message || 'Ordner konnte nicht erstellt werden.';
msg.style.color = '#b9382f';
return;
}
folderInput.value = '';
document.getElementById('destination_subpath').value = data.path;
msg.textContent = 'Ordner erstellt: ' + data.path;
msg.style.color = '#0a7f68';
await browseSmb('destination');
}
function syncEncryptionFields() {
const mode = document.getElementById('encryption_mode').value;
const box = document.getElementById('encryption_password_box');
const pwd = document.getElementById('archive_password');
const pwd2 = document.getElementById('archive_password_confirm');
const visible = mode === 'password-aes256';
box.style.display = visible ? 'block' : 'none';
setRequired(pwd, visible);
setRequired(pwd2, visible);
if (!visible) {
pwd.value = '';
pwd2.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';
}
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('source_subpath').addEventListener('focus', () => {
if (document.getElementById('source_protocol').value === 'SMB') {
browseSmb('source');
}
});
document.getElementById('destination_subpath').addEventListener('focus', () => {
if (document.getElementById('destination_protocol').value === 'SMB') {
browseSmb('destination');
}
});
document.getElementById('source_subpath').addEventListener('click', () => {
if (document.getElementById('source_protocol').value === 'SMB') {
browseSmb('source');
}
});
document.getElementById('destination_subpath').addEventListener('click', () => {
if (document.getElementById('destination_protocol').value === 'SMB') {
browseSmb('destination');
}
});
document.getElementById('encryption_mode').addEventListener('change', syncEncryptionFields);
document.getElementById('schedule_mode').addEventListener('change', syncScheduleFields);
syncProtocolFields('source');
syncProtocolFields('destination');
syncEncryptionFields();
syncScheduleFields();
setSourceEntryType('directory');
showStep(0);
</script>
{% endblock %}
+256
View File
@@ -0,0 +1,256 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}StFV Backup{% endblock %}</title>
<style>
:root {
--paper: #f8fbff;
--ink: #1e2a3b;
--muted: #5a6e87;
--panel: #ffffff;
--line: rgba(30, 42, 59, 0.12);
--accent: #0a7f68;
--accent-soft: #e5fff7;
--danger: #b9382f;
--danger-soft: #ffe9e7;
--shadow: 0 24px 52px rgba(30, 42, 59, 0.13);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: "IBM Plex Sans", "Trebuchet MS", "Noto Sans", sans-serif;
background:
radial-gradient(1000px 550px at -5% -20%, rgba(102, 161, 255, 0.2), transparent 60%),
radial-gradient(700px 450px at 105% 110%, rgba(10, 127, 104, 0.17), transparent 65%),
linear-gradient(165deg, #f2f8ff, #fff9f1);
padding: 22px;
}
.layout {
max-width: 1050px;
margin: 0 auto;
display: grid;
gap: 16px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: var(--shadow);
}
.top {
padding: 18px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand {
margin: 0;
font-size: clamp(22px, 4vw, 32px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.sub {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
}
nav {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.nav-link {
text-decoration: none;
color: var(--ink);
border: 1px solid var(--line);
background: #fff;
border-radius: 11px;
padding: 8px 12px;
font-weight: 700;
font-size: 14px;
}
.nav-link.active {
background: var(--accent-soft);
border-color: rgba(10, 127, 104, 0.35);
color: var(--accent);
}
.logout {
border: 0;
border-radius: 11px;
padding: 9px 13px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #3e4f6f, #2a3851);
cursor: pointer;
}
.messages { display: grid; gap: 10px; }
.msg {
margin: 0;
padding: 10px 12px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
}
.msg.success {
background: var(--accent-soft);
color: var(--accent);
border: 1px solid rgba(10, 127, 104, 0.3);
}
.msg.error {
background: var(--danger-soft);
color: var(--danger);
border: 1px solid rgba(185, 56, 47, 0.3);
}
.content {
padding: 20px;
display: grid;
gap: 16px;
}
.card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
background: #fff;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
h2 {
margin: 0 0 8px;
font-size: clamp(20px, 3vw, 28px);
line-height: 1.1;
}
h3 { margin: 0 0 6px; }
p { margin: 0; color: var(--muted); }
label {
display: block;
margin-bottom: 5px;
font-size: 14px;
font-weight: 700;
color: var(--muted);
}
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
font-size: 14px;
color: var(--ink);
background: #fff;
margin-bottom: 12px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
font-size: 14px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, var(--accent), #02a883);
cursor: pointer;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.users-table th,
.users-table td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 4px 9px;
font-size: 12px;
font-weight: 700;
color: var(--accent);
background: var(--accent-soft);
border: 1px solid rgba(10, 127, 104, 0.35);
}
@media (max-width: 700px) {
.row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="layout">
<section class="panel top">
<div>
<h1 class="brand">StFV Backup</h1>
<p class="sub">Angemeldet als {{ adminuser }}</p>
</div>
<div>
<nav>
<a class="nav-link {% if active_menu == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">Dashboard</a>
<a class="nav-link {% if active_menu == 'backups' %}active{% endif %}" href="{{ url_for('backup_new') }}">Neues Backup</a>
<a class="nav-link {% if active_menu == 'server' %}active{% endif %}" href="{{ url_for('server_settings') }}">Server Settings</a>
<a class="nav-link {% if active_menu == 'account' %}active{% endif %}" href="{{ url_for('account_settings') }}">Admin Konto</a>
<a class="nav-link {% if active_menu == 'users' %}active{% endif %}" href="{{ url_for('users') }}">Benutzer</a>
</nav>
</div>
<form method="post" action="{{ url_for('logout') }}">
<button class="logout" type="submit">Logout</button>
</form>
</section>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section class="panel content messages">
{% for category, message in messages %}
<p class="msg {{ category }}">{{ message }}</p>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="panel content">
{% block content %}{% endblock %}
</section>
</main>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
{% extends "base_admin.html" %}
{% block title %}StFV Backup - Dashboard{% endblock %}
{% block content %}
<style>
.actions {
white-space: nowrap;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
border: 1px solid rgba(30, 42, 59, 0.18);
border-radius: 8px;
background: #fff;
color: #1e2a3b;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
margin-right: 5px;
text-decoration: none;
}
.icon-btn:hover {
background: #f3f8ff;
}
.icon-btn-danger {
color: #b9382f;
border-color: rgba(185, 56, 47, 0.3);
}
</style>
<h2>Eingerichtete Backups</h2>
<p>Hier siehst du alle Backup-Jobs mit Zeitplan, letztem Lauf und Größe.</p>
<div class="card">
<a class="btn" href="{{ url_for('backup_new') }}">Neues Backup einrichten</a>
</div>
{% if backups %}
<table class="users-table">
<thead>
<tr>
<th>Name</th>
<th>Zeitplan</th>
<th>Letzter Lauf</th>
<th>Größe</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% 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 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>
<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>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card">
<p>Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.</p>
</div>
{% endif %}
{% endblock %}
+157
View File
@@ -0,0 +1,157 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>StFV Backup - Login</title>
<style>
:root {
--bg-1: #f7f9fc;
--bg-2: #d9e8ff;
--bg-3: #ffe7c9;
--text: #10223a;
--muted: #3f5875;
--surface: rgba(255, 255, 255, 0.85);
--accent: #007d66;
--accent-strong: #005f4e;
--danger: #c2352a;
--border: rgba(16, 34, 58, 0.12);
--shadow: 0 20px 55px rgba(16, 34, 58, 0.2);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Fira Sans", "Avenir Next", "Noto Sans", sans-serif;
color: var(--text);
background:
radial-gradient(1200px 700px at 10% -20%, var(--bg-2), transparent 65%),
radial-gradient(900px 600px at 90% 110%, var(--bg-3), transparent 60%),
linear-gradient(150deg, #eff5ff, var(--bg-1));
display: grid;
place-items: center;
padding: 24px;
animation: fade-in 450ms ease-out;
}
.shell {
width: min(460px, 100%);
background: var(--surface);
backdrop-filter: blur(10px);
border: 1px solid var(--border);
border-radius: 22px;
box-shadow: var(--shadow);
overflow: hidden;
animation: slide-up 500ms ease-out;
}
.head {
padding: 26px 26px 12px;
}
h1 {
margin: 0;
font-size: clamp(26px, 6vw, 34px);
line-height: 1.08;
letter-spacing: -0.02em;
}
.content {
padding: 18px 26px 28px;
}
.field {
margin-bottom: 16px;
display: grid;
gap: 8px;
}
label {
font-size: 14px;
color: var(--muted);
font-weight: 600;
}
input {
width: 100%;
border: 1px solid var(--border);
background: #ffffff;
border-radius: 14px;
padding: 12px 14px;
font-size: 15px;
color: var(--text);
transition: border-color 180ms ease, box-shadow 180ms ease;
}
input:focus {
outline: none;
border-color: #5ca8ff;
box-shadow: 0 0 0 4px rgba(92, 168, 255, 0.17);
}
.error {
margin: 0 0 14px;
color: var(--danger);
font-size: 14px;
font-weight: 600;
}
button {
width: 100%;
border: 0;
border-radius: 14px;
padding: 13px 14px;
color: #ffffff;
background: linear-gradient(135deg, var(--accent), #00a37f);
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: transform 120ms ease, filter 180ms ease;
}
button:hover {
filter: brightness(1.05);
}
button:active {
transform: translateY(1px);
background: linear-gradient(135deg, var(--accent-strong), #00795f);
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>
</head>
<body>
<main class="shell">
<header class="head">
<h1>StFV Backup</h1>
</header>
<section class="content">
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
<form method="post" action="{{ url_for('login') }}">
<div class="field">
<label for="username">Benutzername</label>
<input id="username" name="username" type="text" autocomplete="username" required>
</div>
<div class="field">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button type="submit">Anmelden</button>
</form>
</section>
</main>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{% extends "base_admin.html" %}
{% block title %}StFV Backup - Server Settings{% endblock %}
{% block content %}
<h2>Server Settings</h2>
<p>Diese Einstellungen gelten fuer den Flask-Webserver.</p>
<div class="card">
<form method="post" action="{{ url_for('server_settings') }}">
<label for="ip">Server Host / IP</label>
<input id="ip" name="ip" type="text" value="{{ config.ip }}" required>
<label for="port">Server Port</label>
<input id="port" name="port" type="number" min="1" max="65535" value="{{ config.port }}" required>
<label for="debug">Debug Modus</label>
<select id="debug" name="debug">
<option value="false" {% if not config.debug %}selected{% endif %}>false</option>
<option value="true" {% if config.debug %}selected{% endif %}>true</option>
</select>
<button class="btn" type="submit">Server Settings speichern</button>
</form>
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base_admin.html" %}
{% block title %}StFV Backup - Benutzer{% endblock %}
{% block content %}
<h2>Benutzerverwaltung</h2>
<p>Lege neue Benutzer fuer den Browser-Login an und steuere Adminrechte.</p>
<div class="grid">
<article class="card">
<h3>Neuen Benutzer anlegen</h3>
<form method="post" action="{{ url_for('users') }}">
<label for="username">Benutzername</label>
<input id="username" name="username" type="text" required>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="new-password" required>
<label for="password_confirm">Passwort bestaetigen</label>
<input id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required>
<label>
<input name="is_admin" type="checkbox" style="width:auto; margin-right:8px;"> Als Admin anlegen
</label>
<button class="btn" type="submit">Benutzer erstellen</button>
</form>
</article>
<article class="card">
<h3>Bestehende Benutzer</h3>
<table class="users-table">
<thead>
<tr>
<th>Benutzername</th>
<th>Rolle</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>
{% if user.is_admin %}
<span class="badge">Admin</span>
{% else %}
Benutzer
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
{% endblock %}