feat: add HTML templates for job management and update GUI application to support backup job CRUD operations
This commit is contained in:
parent
a68685b2f5
commit
0edb707f77
142
gui_app.py
142
gui_app.py
@ -453,6 +453,7 @@ def job_to_display(jid, info):
|
|||||||
'started_fmt': fmt_time(info.get('started')),
|
'started_fmt': fmt_time(info.get('started')),
|
||||||
'dest': info.get('dest', ''),
|
'dest': info.get('dest', ''),
|
||||||
'run_dest': info.get('run_dest', ''),
|
'run_dest': info.get('run_dest', ''),
|
||||||
|
'replication_dest': info.get('replication_dest', ''),
|
||||||
'compress': info.get('compress', False),
|
'compress': info.get('compress', False),
|
||||||
'sftp_host': info.get('sftp_host', ''),
|
'sftp_host': info.get('sftp_host', ''),
|
||||||
'schedule_type': info.get('schedule_type', 'now'),
|
'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}")
|
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):
|
def run_job_thread(jid):
|
||||||
"""Worker executed in a thread (and by APScheduler)."""
|
"""Worker executed in a thread (and by APScheduler)."""
|
||||||
with jobs_db_lock:
|
with jobs_db_lock:
|
||||||
@ -647,6 +693,12 @@ def run_job_thread(jid):
|
|||||||
)
|
)
|
||||||
with jobs_db_lock:
|
with jobs_db_lock:
|
||||||
success_vms.append(vm)
|
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:
|
except Exception as e:
|
||||||
is_cancel_err = "cancelled by user" in str(e).lower()
|
is_cancel_err = "cancelled by user" in str(e).lower()
|
||||||
if is_cancel_err:
|
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(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:
|
with jobs_db_lock:
|
||||||
if failed_vms:
|
if failed_vms:
|
||||||
if success_vms:
|
if success_vms:
|
||||||
@ -723,6 +785,12 @@ def run_job_thread(jid):
|
|||||||
info['status'] = 'finished'
|
info['status'] = 'finished'
|
||||||
info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'}
|
info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'}
|
||||||
save_jobs_db()
|
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:
|
except Exception as e:
|
||||||
with jobs_db_lock:
|
with jobs_db_lock:
|
||||||
if "cancelled by user" in str(e).lower():
|
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)
|
# Always enforce retention policy (which cleans up failed folders immediately)
|
||||||
enforce_retention_policy(info, log_path=log_path)
|
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(
|
def create_and_start_job(
|
||||||
vm_name, dest, compress, no_verify_ssl,
|
vm_name, dest, compress, no_verify_ssl,
|
||||||
@ -742,7 +820,8 @@ def create_and_start_job(
|
|||||||
schedule_type, schedule_time, weekly_day, interval_hours,
|
schedule_type, schedule_time, weekly_day, interval_hours,
|
||||||
label='', disk_filter=None, monthly_day=1, yearly_month=1,
|
label='', disk_filter=None, monthly_day=1, yearly_month=1,
|
||||||
retention_type='keep_all', retention_value=5,
|
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.
|
"""Create a job entry and either run immediately or register schedule.
|
||||||
disk_filter: list of VMDK path strings to include, or None for all.
|
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,
|
'vm_names': vm_names,
|
||||||
'disk_filter_map': disk_filter_map,
|
'disk_filter_map': disk_filter_map,
|
||||||
'dest': dest,
|
'dest': dest,
|
||||||
|
'replication_dest': replication_dest,
|
||||||
'compress': compress,
|
'compress': compress,
|
||||||
'no_verify_ssl': no_verify_ssl,
|
'no_verify_ssl': no_verify_ssl,
|
||||||
'sftp_host': sftp_host,
|
'sftp_host': sftp_host,
|
||||||
@ -897,17 +977,18 @@ def api_vm_disks(vm_name):
|
|||||||
@login_required
|
@login_required
|
||||||
def create_job():
|
def create_job():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
vm_name = request.form.get('vm_name', '').strip()
|
vm_name = request.form.get('vm_name', '').strip()
|
||||||
dest = request.form.get('dest', './backups').strip()
|
dest = request.form.get('dest', './backups').strip()
|
||||||
compress = 'compress' in request.form
|
replication_dest = request.form.get('replication_dest', '').strip() or None
|
||||||
no_verify_ssl = 'no_verify_ssl' in request.form
|
compress = 'compress' in request.form
|
||||||
sftp_host = request.form.get('sftp_host', '').strip() or None
|
no_verify_ssl = 'no_verify_ssl' in request.form
|
||||||
sftp_user = request.form.get('sftp_user', '').strip() or None
|
sftp_host = request.form.get('sftp_host', '').strip() or None
|
||||||
sftp_password = request.form.get('sftp_password', '') or None
|
sftp_user = request.form.get('sftp_user', '').strip() or None
|
||||||
schedule_type = request.form.get('schedule_type', 'now')
|
sftp_password = request.form.get('sftp_password', '') or None
|
||||||
daily_time = request.form.get('daily_time', '02:00')
|
schedule_type = request.form.get('schedule_type', 'now')
|
||||||
weekly_day = request.form.get('weekly_day', '0')
|
daily_time = request.form.get('daily_time', '02:00')
|
||||||
weekly_time = request.form.get('weekly_time', '02:00')
|
weekly_day = request.form.get('weekly_day', '0')
|
||||||
|
weekly_time = request.form.get('weekly_time', '02:00')
|
||||||
|
|
||||||
monthly_basis = request.form.get('monthly_basis', 'day_num')
|
monthly_basis = request.form.get('monthly_basis', 'day_num')
|
||||||
if monthly_basis == 'weekday':
|
if monthly_basis == 'weekday':
|
||||||
@ -972,6 +1053,7 @@ def create_job():
|
|||||||
retention_type=retention_type,
|
retention_type=retention_type,
|
||||||
retention_value=retention_value,
|
retention_value=retention_value,
|
||||||
use_cbt=use_cbt,
|
use_cbt=use_cbt,
|
||||||
|
replication_dest=replication_dest
|
||||||
)
|
)
|
||||||
n_disks = len(disk_filter) if disk_filter is not None else 'all'
|
n_disks = len(disk_filter) if disk_filter is not None else 'all'
|
||||||
flash(f'Job created — {n_disks} disk(s) selected.', 'success')
|
flash(f'Job created — {n_disks} disk(s) selected.', 'success')
|
||||||
@ -1012,15 +1094,16 @@ def batch_jobs():
|
|||||||
vms_by_name = {v['name']: v for v in vm_list}
|
vms_by_name = {v['name']: v for v in vm_list}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
vm_names = request.form.getlist('vms')
|
vm_names = request.form.getlist('vms')
|
||||||
dest = request.form.get('dest', './backups').strip()
|
dest = request.form.get('dest', './backups').strip()
|
||||||
compress = 'compress' in request.form
|
replication_dest = request.form.get('replication_dest', '').strip() or None
|
||||||
no_verify_ssl = 'no_verify_ssl' in request.form
|
compress = 'compress' in request.form
|
||||||
disk_strategy = request.form.get('disk_strategy', 'all')
|
no_verify_ssl = 'no_verify_ssl' in request.form
|
||||||
schedule_type = request.form.get('schedule_type', 'now')
|
disk_strategy = request.form.get('disk_strategy', 'all')
|
||||||
daily_time = request.form.get('daily_time', '02:00')
|
schedule_type = request.form.get('schedule_type', 'now')
|
||||||
weekly_day = request.form.get('weekly_day', '0')
|
daily_time = request.form.get('daily_time', '02:00')
|
||||||
weekly_time = request.form.get('weekly_time', '02:00')
|
weekly_day = request.form.get('weekly_day', '0')
|
||||||
|
weekly_time = request.form.get('weekly_time', '02:00')
|
||||||
|
|
||||||
monthly_basis = request.form.get('monthly_basis', 'day_num')
|
monthly_basis = request.form.get('monthly_basis', 'day_num')
|
||||||
if monthly_basis == 'weekday':
|
if monthly_basis == 'weekday':
|
||||||
@ -1092,6 +1175,7 @@ def batch_jobs():
|
|||||||
vm_names=vm_names,
|
vm_names=vm_names,
|
||||||
disk_filter_map=disk_filter_map,
|
disk_filter_map=disk_filter_map,
|
||||||
use_cbt=use_cbt,
|
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)
|
strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy)
|
||||||
@ -1302,13 +1386,14 @@ def edit_job(jobid):
|
|||||||
return redirect(url_for('job_detail', jobid=jobid))
|
return redirect(url_for('job_detail', jobid=jobid))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
dest = request.form.get('dest', './backups').strip()
|
dest = request.form.get('dest', './backups').strip()
|
||||||
compress = 'compress' in request.form
|
replication_dest = request.form.get('replication_dest', '').strip() or None
|
||||||
no_verify_ssl = 'no_verify_ssl' in request.form
|
compress = 'compress' in request.form
|
||||||
schedule_type = request.form.get('schedule_type', 'now')
|
no_verify_ssl = 'no_verify_ssl' in request.form
|
||||||
daily_time = request.form.get('daily_time', '02:00')
|
schedule_type = request.form.get('schedule_type', 'now')
|
||||||
weekly_day = request.form.get('weekly_day', '0')
|
daily_time = request.form.get('daily_time', '02:00')
|
||||||
weekly_time = request.form.get('weekly_time', '02:00')
|
weekly_day = request.form.get('weekly_day', '0')
|
||||||
|
weekly_time = request.form.get('weekly_time', '02:00')
|
||||||
|
|
||||||
monthly_basis = request.form.get('monthly_basis', 'day_num')
|
monthly_basis = request.form.get('monthly_basis', 'day_num')
|
||||||
if monthly_basis == 'weekday':
|
if monthly_basis == 'weekday':
|
||||||
@ -1352,6 +1437,7 @@ def edit_job(jobid):
|
|||||||
# Update job config
|
# Update job config
|
||||||
info['label'] = label
|
info['label'] = label
|
||||||
info['dest'] = dest
|
info['dest'] = dest
|
||||||
|
info['replication_dest'] = replication_dest
|
||||||
info['compress'] = compress
|
info['compress'] = compress
|
||||||
info['no_verify_ssl'] = no_verify_ssl
|
info['no_verify_ssl'] = no_verify_ssl
|
||||||
info['use_cbt'] = use_cbt
|
info['use_cbt'] = use_cbt
|
||||||
|
|||||||
@ -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>
|
Each VM will be backed up into its own subfolder: <code style="font-family:'JetBrains Mono',monospace;">dest/VM_NAME/</code>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-check">
|
||||||
<input type="checkbox" id="compress" name="compress" />
|
<input type="checkbox" id="compress" name="compress" />
|
||||||
<label for="compress">Compress with zstd (smaller files, slower)</label>
|
<label for="compress">Compress with zstd (smaller files, slower)</label>
|
||||||
@ -650,7 +663,11 @@
|
|||||||
if (!mounts || !mounts.length) return;
|
if (!mounts || !mounts.length) return;
|
||||||
const wrap = document.getElementById('nfsTargets');
|
const wrap = document.getElementById('nfsTargets');
|
||||||
const list = document.getElementById('nfsMountList');
|
const list = document.getElementById('nfsMountList');
|
||||||
|
const repWrap = document.getElementById('repNfsTargets');
|
||||||
|
const repList = document.getElementById('repNfsMountList');
|
||||||
|
|
||||||
mounts.forEach(m => {
|
mounts.forEach(m => {
|
||||||
|
// Destination Button
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'btn btn-secondary btn-sm';
|
btn.className = 'btn btn-secondary btn-sm';
|
||||||
@ -661,8 +678,21 @@
|
|||||||
btn.style.borderColor = 'var(--accent)';
|
btn.style.borderColor = 'var(--accent)';
|
||||||
};
|
};
|
||||||
list.appendChild(btn);
|
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 = '';
|
wrap.style.display = '';
|
||||||
|
if (repWrap) repWrap.style.display = '';
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
|||||||
@ -235,6 +235,16 @@
|
|||||||
<input id="dest" class="form-control" type="text" name="dest"
|
<input id="dest" class="form-control" type="text" name="dest"
|
||||||
value="./backups" placeholder="e.g. /mnt/nfs-backup or /data/vmbackups" required />
|
value="./backups" placeholder="e.g. /mnt/nfs-backup or /data/vmbackups" required />
|
||||||
</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>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" id="compress" name="compress" />
|
<input type="checkbox" id="compress" name="compress" />
|
||||||
<label for="compress">Compress with zstd (smaller files, slower)</label>
|
<label for="compress">Compress with zstd (smaller files, slower)</label>
|
||||||
@ -735,7 +745,11 @@
|
|||||||
if (!mounts || !mounts.length) return;
|
if (!mounts || !mounts.length) return;
|
||||||
const wrap = document.getElementById('nfsTargets');
|
const wrap = document.getElementById('nfsTargets');
|
||||||
const list = document.getElementById('nfsMountList');
|
const list = document.getElementById('nfsMountList');
|
||||||
|
const repWrap = document.getElementById('repNfsTargets');
|
||||||
|
const repList = document.getElementById('repNfsMountList');
|
||||||
|
|
||||||
mounts.forEach(m => {
|
mounts.forEach(m => {
|
||||||
|
// Destination Button
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'btn btn-secondary btn-sm';
|
btn.className = 'btn btn-secondary btn-sm';
|
||||||
@ -746,8 +760,21 @@
|
|||||||
btn.style.borderColor = 'var(--accent)';
|
btn.style.borderColor = 'var(--accent)';
|
||||||
};
|
};
|
||||||
list.appendChild(btn);
|
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 = '';
|
wrap.style.display = '';
|
||||||
|
if (repWrap) repWrap.style.display = '';
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
|||||||
@ -230,6 +230,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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;">
|
<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;">
|
<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);" />
|
<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;
|
if (!mounts || !mounts.length) return;
|
||||||
const wrap = document.getElementById('nfsTargets');
|
const wrap = document.getElementById('nfsTargets');
|
||||||
const list = document.getElementById('nfsMountList');
|
const list = document.getElementById('nfsMountList');
|
||||||
|
const repWrap = document.getElementById('repNfsTargets');
|
||||||
|
const repList = document.getElementById('repNfsMountList');
|
||||||
|
|
||||||
mounts.forEach(m => {
|
mounts.forEach(m => {
|
||||||
|
// Destination Button
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'btn btn-secondary btn-sm';
|
btn.className = 'btn btn-secondary btn-sm';
|
||||||
@ -584,8 +598,21 @@
|
|||||||
btn.style.borderColor = 'var(--accent)';
|
btn.style.borderColor = 'var(--accent)';
|
||||||
};
|
};
|
||||||
list.appendChild(btn);
|
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 = '';
|
wrap.style.display = '';
|
||||||
|
if (repWrap) repWrap.style.display = '';
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
|||||||
@ -247,6 +247,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="detail-item-label">Retention Policy</div>
|
<div class="detail-item-label">Retention Policy</div>
|
||||||
<div class="detail-item-val" style="font-size:13px;">
|
<div class="detail-item-val" style="font-size:13px;">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user