feat: add HTML templates for job management and update GUI application to support backup job CRUD operations

This commit is contained in:
Rizqi 2026-06-26 12:58:01 +07:00
parent a68685b2f5
commit 0edb707f77
5 changed files with 204 additions and 28 deletions

View File

@ -453,6 +453,7 @@ def job_to_display(jid, info):
'started_fmt': fmt_time(info.get('started')),
'dest': info.get('dest', ''),
'run_dest': info.get('run_dest', ''),
'replication_dest': info.get('replication_dest', ''),
'compress': info.get('compress', False),
'sftp_host': info.get('sftp_host', ''),
'schedule_type': info.get('schedule_type', 'now'),
@ -561,6 +562,51 @@ def enforce_retention_policy(info, log_path=None):
log_msg(f"ERROR during retention cleanup: {e}")
def replicate_backup_folder(src_dir, dest_dir, log_path=None):
"""
Copy all files from primary backup folder to replication target folder,
then verify checksums.
"""
def log_msg(msg):
print(msg)
if log_path:
try:
with open(log_path, 'a', encoding='utf-8') as f:
f.write(f"[Replication] {msg}\n")
except Exception:
pass
log_msg(f"Starting replication from '{src_dir}' to '{dest_dir}'...")
if not os.path.exists(src_dir):
log_msg(f"ERROR: Source directory '{src_dir}' does not exist.")
return False
try:
os.makedirs(dest_dir, exist_ok=True)
import shutil
for item in os.listdir(src_dir):
s = os.path.join(src_dir, item)
d = os.path.join(dest_dir, item)
if os.path.isdir(s):
shutil.copytree(s, d, dirs_exist_ok=True)
else:
shutil.copy2(s, d)
log_msg(f"Replication file copy completed successfully.")
# Verify checksums on replication target folder
log_msg("Verifying checksums on replication target...")
from backup_core import verify_backup_checksums
if verify_backup_checksums(dest_dir):
log_msg("Replication verification OK: all SHA-256 checksums match.")
return True
else:
log_msg("WARNING: Replication verification FAILED on target. Checksums do not match.")
return False
except Exception as e:
log_msg(f"ERROR during replication: {e}")
return False
def run_job_thread(jid):
"""Worker executed in a thread (and by APScheduler)."""
with jobs_db_lock:
@ -647,6 +693,12 @@ def run_job_thread(jid):
)
with jobs_db_lock:
success_vms.append(vm)
# Replicate successful backup if replication_dest is configured
rep_dest = info.get('replication_dest')
if rep_dest:
rep_vm_dest = os.path.join(rep_dest, vm, f"backup-{run_timestamp}")
replicate_backup_folder(vm_dest, rep_vm_dest, log_path=log_path)
except Exception as e:
is_cancel_err = "cancelled by user" in str(e).lower()
if is_cancel_err:
@ -671,6 +723,16 @@ def run_job_thread(jid):
}
enforce_retention_policy(vm_info, log_path=log_path)
# Enforce retention policy on replication target if configured
if info.get('replication_dest'):
rep_vm_info = {
'vm_name': vm,
'dest': info['replication_dest'],
'retention_type': info.get('retention_type', 'keep_all'),
'retention_value': info.get('retention_value', 5)
}
enforce_retention_policy(rep_vm_info, log_path=log_path)
with jobs_db_lock:
if failed_vms:
if success_vms:
@ -723,6 +785,12 @@ def run_job_thread(jid):
info['status'] = 'finished'
info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'}
save_jobs_db()
# Replicate successful backup if replication_dest is configured
rep_dest = info.get('replication_dest')
if rep_dest:
rep_run_dest = os.path.join(rep_dest, info['vm_name'], f"backup-{run_timestamp}")
replicate_backup_folder(run_dest, rep_run_dest, log_path=log_path)
except Exception as e:
with jobs_db_lock:
if "cancelled by user" in str(e).lower():
@ -735,6 +803,16 @@ def run_job_thread(jid):
# Always enforce retention policy (which cleans up failed folders immediately)
enforce_retention_policy(info, log_path=log_path)
# Enforce retention policy on replication target if configured
if info.get('replication_dest'):
rep_info = {
'vm_name': info['vm_name'],
'dest': info['replication_dest'],
'retention_type': info.get('retention_type', 'keep_all'),
'retention_value': info.get('retention_value', 5)
}
enforce_retention_policy(rep_info, log_path=log_path)
def create_and_start_job(
vm_name, dest, compress, no_verify_ssl,
@ -742,7 +820,8 @@ def create_and_start_job(
schedule_type, schedule_time, weekly_day, interval_hours,
label='', disk_filter=None, monthly_day=1, yearly_month=1,
retention_type='keep_all', retention_value=5,
vm_names=None, disk_filter_map=None, use_cbt=False
vm_names=None, disk_filter_map=None, use_cbt=False,
replication_dest=None
):
"""Create a job entry and either run immediately or register schedule.
disk_filter: list of VMDK path strings to include, or None for all.
@ -763,6 +842,7 @@ def create_and_start_job(
'vm_names': vm_names,
'disk_filter_map': disk_filter_map,
'dest': dest,
'replication_dest': replication_dest,
'compress': compress,
'no_verify_ssl': no_verify_ssl,
'sftp_host': sftp_host,
@ -899,6 +979,7 @@ def create_job():
if request.method == 'POST':
vm_name = request.form.get('vm_name', '').strip()
dest = request.form.get('dest', './backups').strip()
replication_dest = request.form.get('replication_dest', '').strip() or None
compress = 'compress' in request.form
no_verify_ssl = 'no_verify_ssl' in request.form
sftp_host = request.form.get('sftp_host', '').strip() or None
@ -972,6 +1053,7 @@ def create_job():
retention_type=retention_type,
retention_value=retention_value,
use_cbt=use_cbt,
replication_dest=replication_dest
)
n_disks = len(disk_filter) if disk_filter is not None else 'all'
flash(f'Job created — {n_disks} disk(s) selected.', 'success')
@ -1014,6 +1096,7 @@ def batch_jobs():
if request.method == 'POST':
vm_names = request.form.getlist('vms')
dest = request.form.get('dest', './backups').strip()
replication_dest = request.form.get('replication_dest', '').strip() or None
compress = 'compress' in request.form
no_verify_ssl = 'no_verify_ssl' in request.form
disk_strategy = request.form.get('disk_strategy', 'all')
@ -1092,6 +1175,7 @@ def batch_jobs():
vm_names=vm_names,
disk_filter_map=disk_filter_map,
use_cbt=use_cbt,
replication_dest=replication_dest
)
strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy)
@ -1303,6 +1387,7 @@ def edit_job(jobid):
if request.method == 'POST':
dest = request.form.get('dest', './backups').strip()
replication_dest = request.form.get('replication_dest', '').strip() or None
compress = 'compress' in request.form
no_verify_ssl = 'no_verify_ssl' in request.form
schedule_type = request.form.get('schedule_type', 'now')
@ -1352,6 +1437,7 @@ def edit_job(jobid):
# Update job config
info['label'] = label
info['dest'] = dest
info['replication_dest'] = replication_dest
info['compress'] = compress
info['no_verify_ssl'] = no_verify_ssl
info['use_cbt'] = use_cbt

View File

@ -242,6 +242,19 @@
Each VM will be backed up into its own subfolder: <code style="font-family:'JetBrains Mono',monospace;">dest/VM_NAME/</code>
</div>
</div>
<div class="form-group">
<label class="form-label" for="replication_dest">Replication target path (optional NFS/local)</label>
<input id="replication_dest" class="form-control" type="text" name="replication_dest"
placeholder="e.g. /mnt/nfs-backup-replica" />
<div id="repNfsTargets" style="margin-top:10px; display:none;">
<div class="form-label" style="margin-bottom:6px; font-size: 11px;">Quick-Select Replication Target</div>
<div id="repNfsMountList" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:6px;">
Successful VM backups will be replicated sequentially to: <code style="font-family:'JetBrains Mono',monospace;">replication_dest/VM_NAME/</code>
</div>
</div>
<div class="form-check">
<input type="checkbox" id="compress" name="compress" />
<label for="compress">Compress with zstd (smaller files, slower)</label>
@ -650,7 +663,11 @@
if (!mounts || !mounts.length) return;
const wrap = document.getElementById('nfsTargets');
const list = document.getElementById('nfsMountList');
const repWrap = document.getElementById('repNfsTargets');
const repList = document.getElementById('repNfsMountList');
mounts.forEach(m => {
// Destination Button
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-secondary btn-sm';
@ -661,8 +678,21 @@
btn.style.borderColor = 'var(--accent)';
};
list.appendChild(btn);
// Replication Button
const repBtn = document.createElement('button');
repBtn.type = 'button';
repBtn.className = 'btn btn-secondary btn-sm';
repBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> ${m.mountpoint} <span style="color:var(--text-muted);font-size:11px;margin-left:4px;">${m.free_gb}GB free</span>`;
repBtn.onclick = () => {
document.getElementById('replication_dest').value = m.mountpoint;
repList.querySelectorAll('button').forEach(b => b.style.borderColor = '');
repBtn.style.borderColor = 'var(--accent)';
};
repList.appendChild(repBtn);
});
wrap.style.display = '';
if (repWrap) repWrap.style.display = '';
})
.catch(() => {});

View File

@ -235,6 +235,16 @@
<input id="dest" class="form-control" type="text" name="dest"
value="./backups" placeholder="e.g. /mnt/nfs-backup or /data/vmbackups" required />
</div>
<div class="form-group">
<label class="form-label" for="replication_dest">Replication target path (optional NFS/local)</label>
<input id="replication_dest" class="form-control" type="text" name="replication_dest"
placeholder="e.g. /mnt/nfs-backup-replica" />
<div id="repNfsTargets" style="margin-top:10px; display:none;">
<div class="form-label" style="margin-bottom:6px; font-size: 11px;">Quick-Select Replication Target</div>
<div id="repNfsMountList" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
</div>
</div>
<div class="form-check">
<input type="checkbox" id="compress" name="compress" />
<label for="compress">Compress with zstd (smaller files, slower)</label>
@ -735,7 +745,11 @@
if (!mounts || !mounts.length) return;
const wrap = document.getElementById('nfsTargets');
const list = document.getElementById('nfsMountList');
const repWrap = document.getElementById('repNfsTargets');
const repList = document.getElementById('repNfsMountList');
mounts.forEach(m => {
// Destination Button
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-secondary btn-sm';
@ -746,8 +760,21 @@
btn.style.borderColor = 'var(--accent)';
};
list.appendChild(btn);
// Replication Button
const repBtn = document.createElement('button');
repBtn.type = 'button';
repBtn.className = 'btn btn-secondary btn-sm';
repBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px; vertical-align: middle;"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> ${m.mountpoint} <span style="color:var(--text-muted);font-size:11px;margin-left:4px;">${m.free_gb}GB free</span>`;
repBtn.onclick = () => {
document.getElementById('replication_dest').value = m.mountpoint;
repList.querySelectorAll('button').forEach(b => b.style.borderColor = '');
repBtn.style.borderColor = 'var(--accent)';
};
repList.appendChild(repBtn);
});
wrap.style.display = '';
if (repWrap) repWrap.style.display = '';
})
.catch(() => {});

View File

@ -230,6 +230,16 @@
</div>
</div>
<div class="form-group">
<label class="form-label" for="replication_dest">Replication target path (optional NFS/local)</label>
<input id="replication_dest" class="form-control" type="text" name="replication_dest" value="{{ job.replication_dest }}" placeholder="e.g. /mnt/nfs-backup-replica" />
<div class="nfs-targets" id="repNfsTargets" style="display:none; margin-top: 10px;">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);text-transform:uppercase;">Quick-Select Replication Target</div>
<div class="nfs-mount-list" id="repNfsMountList"></div>
</div>
</div>
<div style="display:flex; gap:20px; flex-wrap:wrap; margin-top:16px;">
<label style="display:flex; align-items:center; gap:8px; font-size:13.5px; cursor:pointer;">
<input type="checkbox" name="compress" {% if job.compress %}checked{% endif %} style="width:18px;height:18px;accent-color:var(--accent);" />
@ -573,7 +583,11 @@
if (!mounts || !mounts.length) return;
const wrap = document.getElementById('nfsTargets');
const list = document.getElementById('nfsMountList');
const repWrap = document.getElementById('repNfsTargets');
const repList = document.getElementById('repNfsMountList');
mounts.forEach(m => {
// Destination Button
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-secondary btn-sm';
@ -584,8 +598,21 @@
btn.style.borderColor = 'var(--accent)';
};
list.appendChild(btn);
// Replication Button
const repBtn = document.createElement('button');
repBtn.type = 'button';
repBtn.className = 'btn btn-secondary btn-sm';
repBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px; vertical-align: middle;"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> ${m.mountpoint} <span style="color:var(--text-muted);font-size:11px;margin-left:4px;">${m.free_gb}GB free</span>`;
repBtn.onclick = () => {
document.getElementById('replication_dest').value = m.mountpoint;
repList.querySelectorAll('button').forEach(b => b.style.borderColor = '');
repBtn.style.borderColor = 'var(--accent)';
};
repList.appendChild(repBtn);
});
wrap.style.display = '';
if (repWrap) repWrap.style.display = '';
})
.catch(() => {});

View File

@ -247,6 +247,12 @@
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Replication Target</div>
<div class="detail-item-val mono" style="font-size:12px; word-break:break-all; cursor:pointer;" data-copy="{{ job.replication_dest or '' }}" title="Click to copy">
{{ job.replication_dest or '—' }}
</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Retention Policy</div>
<div class="detail-item-val" style="font-size:13px;">