feat: add persistent job storage, automated scheduling, and backup retention management
This commit is contained in:
parent
5a1e254ad2
commit
2598369051
207
gui_app.py
207
gui_app.py
@ -42,18 +42,103 @@ BASE_DIR = Path(__file__).resolve().parent
|
|||||||
JOBS_DIR = BASE_DIR / 'jobs'
|
JOBS_DIR = BASE_DIR / 'jobs'
|
||||||
JOBS_DIR.mkdir(exist_ok=True)
|
JOBS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
JOBS_DB_PATH = BASE_DIR / 'jobs.json'
|
||||||
|
jobs_db_lock = threading.Lock()
|
||||||
|
|
||||||
# In-memory job store: {job_id: job_dict}
|
# In-memory job store: {job_id: job_dict}
|
||||||
# job_dict keys: id, label, vm_name, status, started, dest, compress,
|
|
||||||
# no_verify_ssl, sftp_host, sftp_user, sftp_password,
|
|
||||||
# log, schedule_type, schedule_time, schedule_id
|
|
||||||
jobs: dict = {}
|
jobs: dict = {}
|
||||||
|
|
||||||
|
def load_jobs_db():
|
||||||
|
global jobs
|
||||||
|
if JOBS_DB_PATH.exists():
|
||||||
|
try:
|
||||||
|
with open(JOBS_DB_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
with jobs_db_lock:
|
||||||
|
jobs.clear()
|
||||||
|
jobs.update(json.load(f))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to load jobs database: {e}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
with jobs_db_lock:
|
||||||
|
jobs.clear()
|
||||||
|
|
||||||
|
def save_jobs_db():
|
||||||
|
with jobs_db_lock:
|
||||||
|
try:
|
||||||
|
with open(JOBS_DB_PATH, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(jobs, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to save jobs database: {e}", file=sys.stderr)
|
||||||
|
|
||||||
# APScheduler instance
|
# APScheduler instance
|
||||||
scheduler = None
|
scheduler = None
|
||||||
if HAS_SCHEDULER:
|
if HAS_SCHEDULER:
|
||||||
scheduler = BackgroundScheduler(daemon=True)
|
scheduler = BackgroundScheduler(daemon=True)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
def reschedule_active_jobs():
|
||||||
|
if not HAS_SCHEDULER or not scheduler:
|
||||||
|
return
|
||||||
|
rescheduled_count = 0
|
||||||
|
for jid, info in list(jobs.items()):
|
||||||
|
if info.get('status') == 'scheduled' and info.get('schedule_id'):
|
||||||
|
try:
|
||||||
|
# Remove first to prevent duplicates
|
||||||
|
try:
|
||||||
|
scheduler.remove_job(info['schedule_id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
trigger = None
|
||||||
|
schedule_type = info.get('schedule_type')
|
||||||
|
schedule_time = info.get('schedule_time', '')
|
||||||
|
weekly_day = info.get('weekly_day', '0')
|
||||||
|
monthly_day = info.get('monthly_day', '1')
|
||||||
|
interval_hours = info.get('interval_hours', '24')
|
||||||
|
vm_name = info.get('vm_name')
|
||||||
|
label = info.get('label', '')
|
||||||
|
|
||||||
|
if schedule_type == 'daily':
|
||||||
|
hour, minute = (schedule_time.split(':') + ['00'])[:2]
|
||||||
|
trigger = CronTrigger(hour=int(hour), minute=int(minute))
|
||||||
|
elif schedule_type == 'weekly':
|
||||||
|
hour, minute = (schedule_time.split(':') + ['00'])[:2]
|
||||||
|
trigger = CronTrigger(
|
||||||
|
day_of_week=int(weekly_day),
|
||||||
|
hour=int(hour), minute=int(minute)
|
||||||
|
)
|
||||||
|
elif schedule_type == 'monthly':
|
||||||
|
hour, minute = (schedule_time.split(':') + ['00'])[:2]
|
||||||
|
trigger = CronTrigger(
|
||||||
|
day=max(1, min(28, int(monthly_day or 1))),
|
||||||
|
hour=int(hour), minute=int(minute)
|
||||||
|
)
|
||||||
|
elif schedule_type == 'interval':
|
||||||
|
trigger = IntervalTrigger(hours=max(1, int(interval_hours or 24)))
|
||||||
|
|
||||||
|
if trigger:
|
||||||
|
def make_runner(j):
|
||||||
|
def _runner():
|
||||||
|
run_job_thread(j)
|
||||||
|
return _runner
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
make_runner(jid),
|
||||||
|
trigger=trigger,
|
||||||
|
id=info['schedule_id'],
|
||||||
|
name=f"Backup {vm_name} ({label or jid[:8]})",
|
||||||
|
misfire_grace_time=3600,
|
||||||
|
max_instances=1,
|
||||||
|
)
|
||||||
|
rescheduled_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to reschedule job {jid}: {e}", file=sys.stderr)
|
||||||
|
print(f"Loaded {len(jobs)} jobs and re-scheduled {rescheduled_count} jobs.")
|
||||||
|
|
||||||
|
# Load database and reschedule active tasks on startup
|
||||||
|
load_jobs_db()
|
||||||
|
reschedule_active_jobs()
|
||||||
|
|
||||||
# ── VM list cache ─────────────────────────────────────────────────────────────
|
# ── VM list cache ─────────────────────────────────────────────────────────────
|
||||||
# Keyed by (host, user) so different users get separate caches.
|
# Keyed by (host, user) so different users get separate caches.
|
||||||
_vm_cache: dict = {} # key -> {'vms': [...], 'ts': float, 'error': str|None}
|
_vm_cache: dict = {} # key -> {'vms': [...], 'ts': float, 'error': str|None}
|
||||||
@ -212,6 +297,7 @@ def job_to_display(jid, info):
|
|||||||
'status': info.get('status', 'unknown'),
|
'status': info.get('status', 'unknown'),
|
||||||
'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', ''),
|
||||||
'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'),
|
||||||
@ -219,9 +305,83 @@ def job_to_display(jid, info):
|
|||||||
'schedule_id': info.get('schedule_id'),
|
'schedule_id': info.get('schedule_id'),
|
||||||
'disk_filter': disk_filter,
|
'disk_filter': disk_filter,
|
||||||
'disks_count': len(disk_filter) if disk_filter is not None else None,
|
'disks_count': len(disk_filter) if disk_filter is not None else None,
|
||||||
|
'retention_type': info.get('retention_type', 'keep_all'),
|
||||||
|
'retention_value': info.get('retention_value', 5),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_retention_policy(info, log_path=None):
|
||||||
|
def log_msg(msg):
|
||||||
|
print(msg)
|
||||||
|
if log_path:
|
||||||
|
try:
|
||||||
|
with open(log_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(f"[Retention] {msg}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
retention_type = info.get('retention_type', 'keep_all')
|
||||||
|
retention_val = info.get('retention_value', 5)
|
||||||
|
if retention_type == 'keep_all':
|
||||||
|
return
|
||||||
|
|
||||||
|
vm_name = info.get('vm_name')
|
||||||
|
parent_dest = info.get('dest')
|
||||||
|
if not vm_name or not parent_dest:
|
||||||
|
return
|
||||||
|
|
||||||
|
vm_dir = os.path.join(parent_dest, vm_name)
|
||||||
|
if not os.path.exists(vm_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
subdirs = []
|
||||||
|
for name in os.listdir(vm_dir):
|
||||||
|
path = os.path.join(vm_dir, name)
|
||||||
|
if os.path.isdir(path) and name.startswith('backup-'):
|
||||||
|
subdirs.append((name, path))
|
||||||
|
|
||||||
|
# Sort chronologically by folder name (backup-YYYYMMDDHHMMSS)
|
||||||
|
subdirs.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
if retention_type == 'keep_count':
|
||||||
|
if len(subdirs) > retention_val:
|
||||||
|
to_delete = subdirs[:-retention_val]
|
||||||
|
log_msg(f"Enforcing count retention (keep {retention_val}). Deleting {len(to_delete)} old backup(s)...")
|
||||||
|
for name, path in to_delete:
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(path)
|
||||||
|
log_msg(f"Deleted old backup directory: {name}")
|
||||||
|
except Exception as e:
|
||||||
|
log_msg(f"ERROR deleting {name}: {e}")
|
||||||
|
|
||||||
|
elif retention_type == 'keep_days':
|
||||||
|
import shutil
|
||||||
|
cutoff_time = time.time() - (retention_val * 86400)
|
||||||
|
deleted_count = 0
|
||||||
|
for name, path in subdirs:
|
||||||
|
try:
|
||||||
|
ts_str = name[7:]
|
||||||
|
dt = datetime.strptime(ts_str[:14], '%Y%m%d%H%M%S')
|
||||||
|
folder_time = dt.timestamp()
|
||||||
|
except Exception:
|
||||||
|
folder_time = os.path.getmtime(path)
|
||||||
|
|
||||||
|
if folder_time < cutoff_time:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
log_msg(f"Deleted backup older than {retention_val} days: {name}")
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
log_msg(f"ERROR deleting {name}: {e}")
|
||||||
|
if deleted_count > 0:
|
||||||
|
log_msg(f"Enforced age retention. Deleted {deleted_count} backups.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_msg(f"ERROR during retention cleanup: {e}")
|
||||||
|
|
||||||
|
|
||||||
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)."""
|
||||||
info = jobs.get(jid)
|
info = jobs.get(jid)
|
||||||
@ -230,6 +390,14 @@ def run_job_thread(jid):
|
|||||||
info['status'] = 'running'
|
info['status'] = 'running'
|
||||||
info['started'] = time.time()
|
info['started'] = time.time()
|
||||||
info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'}
|
info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'}
|
||||||
|
|
||||||
|
# Create run-specific destination folder to prevent overwrites
|
||||||
|
run_timestamp = datetime.fromtimestamp(info['started']).strftime('%Y%m%d%H%M%S')
|
||||||
|
run_dest = os.path.join(info['dest'], info['vm_name'], f"backup-{run_timestamp}")
|
||||||
|
info['run_dest'] = run_dest
|
||||||
|
|
||||||
|
save_jobs_db()
|
||||||
|
|
||||||
log_path = str(JOBS_DIR / jid / 'backup.log')
|
log_path = str(JOBS_DIR / jid / 'backup.log')
|
||||||
|
|
||||||
def progress_cb(prog):
|
def progress_cb(prog):
|
||||||
@ -241,7 +409,7 @@ def run_job_thread(jid):
|
|||||||
user=info['user'],
|
user=info['user'],
|
||||||
password=info['password'],
|
password=info['password'],
|
||||||
vm_name=info['vm_name'],
|
vm_name=info['vm_name'],
|
||||||
dest=info['dest'],
|
dest=run_dest,
|
||||||
compress=info.get('compress', False),
|
compress=info.get('compress', False),
|
||||||
no_verify_ssl=info.get('no_verify_ssl', False),
|
no_verify_ssl=info.get('no_verify_ssl', False),
|
||||||
sftp_host=info.get('sftp_host') or None,
|
sftp_host=info.get('sftp_host') or None,
|
||||||
@ -254,15 +422,21 @@ 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()
|
||||||
|
|
||||||
|
# Enforce retention policy
|
||||||
|
enforce_retention_policy(info, log_path=log_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
info['status'] = f'failed ({e})'
|
info['status'] = f'failed ({e})'
|
||||||
|
save_jobs_db()
|
||||||
|
|
||||||
|
|
||||||
def create_and_start_job(
|
def create_and_start_job(
|
||||||
vm_name, dest, compress, no_verify_ssl,
|
vm_name, dest, compress, no_verify_ssl,
|
||||||
sftp_host, sftp_user, sftp_password,
|
sftp_host, sftp_user, sftp_password,
|
||||||
schedule_type, schedule_time, weekly_day, interval_hours,
|
schedule_type, schedule_time, weekly_day, interval_hours,
|
||||||
label='', disk_filter=None, monthly_day=1
|
label='', disk_filter=None, monthly_day=1,
|
||||||
|
retention_type='keep_all', retention_value=5
|
||||||
):
|
):
|
||||||
"""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.
|
||||||
@ -291,6 +465,11 @@ def create_and_start_job(
|
|||||||
'schedule_time': schedule_time,
|
'schedule_time': schedule_time,
|
||||||
'schedule_id': None,
|
'schedule_id': None,
|
||||||
'disk_filter': disk_filter, # None = back up all disks
|
'disk_filter': disk_filter, # None = back up all disks
|
||||||
|
'weekly_day': weekly_day,
|
||||||
|
'monthly_day': monthly_day,
|
||||||
|
'interval_hours': interval_hours,
|
||||||
|
'retention_type': retention_type,
|
||||||
|
'retention_value': retention_value,
|
||||||
}
|
}
|
||||||
jobs[jid] = info
|
jobs[jid] = info
|
||||||
|
|
||||||
@ -340,6 +519,7 @@ def create_and_start_job(
|
|||||||
t = threading.Thread(target=run_job_thread, args=(jid,), daemon=True)
|
t = threading.Thread(target=run_job_thread, args=(jid,), daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
|
save_jobs_db()
|
||||||
return jid
|
return jid
|
||||||
|
|
||||||
|
|
||||||
@ -476,6 +656,12 @@ def create_job():
|
|||||||
else:
|
else:
|
||||||
disk_filter = None # disks not shown yet = backup all
|
disk_filter = None # disks not shown yet = backup all
|
||||||
|
|
||||||
|
retention_type = request.form.get('retention_type', 'keep_all')
|
||||||
|
try:
|
||||||
|
retention_value = int(request.form.get('retention_value', '5'))
|
||||||
|
except ValueError:
|
||||||
|
retention_value = 5
|
||||||
|
|
||||||
jid = create_and_start_job(
|
jid = create_and_start_job(
|
||||||
vm_name=vm_name,
|
vm_name=vm_name,
|
||||||
dest=dest,
|
dest=dest,
|
||||||
@ -491,6 +677,8 @@ def create_job():
|
|||||||
label=label,
|
label=label,
|
||||||
disk_filter=disk_filter,
|
disk_filter=disk_filter,
|
||||||
monthly_day=monthly_day,
|
monthly_day=monthly_day,
|
||||||
|
retention_type=retention_type,
|
||||||
|
retention_value=retention_value,
|
||||||
)
|
)
|
||||||
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')
|
||||||
@ -570,6 +758,12 @@ def batch_jobs():
|
|||||||
|
|
||||||
label = f'{label_prefix} — {vm_name}' if label_prefix else vm_name
|
label = f'{label_prefix} — {vm_name}' if label_prefix else vm_name
|
||||||
|
|
||||||
|
retention_type = request.form.get('retention_type', 'keep_all')
|
||||||
|
try:
|
||||||
|
retention_value = int(request.form.get('retention_value', '5'))
|
||||||
|
except ValueError:
|
||||||
|
retention_value = 5
|
||||||
|
|
||||||
jid = create_and_start_job(
|
jid = create_and_start_job(
|
||||||
vm_name=vm_name,
|
vm_name=vm_name,
|
||||||
dest=dest,
|
dest=dest,
|
||||||
@ -585,6 +779,8 @@ def batch_jobs():
|
|||||||
label=label,
|
label=label,
|
||||||
disk_filter=disk_filter,
|
disk_filter=disk_filter,
|
||||||
monthly_day=monthly_day,
|
monthly_day=monthly_day,
|
||||||
|
retention_type=retention_type,
|
||||||
|
retention_value=retention_value,
|
||||||
)
|
)
|
||||||
created.append(jid)
|
created.append(jid)
|
||||||
|
|
||||||
@ -671,6 +867,7 @@ def cancel_schedule(jobid):
|
|||||||
pass
|
pass
|
||||||
info['schedule_id'] = None
|
info['schedule_id'] = None
|
||||||
info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status']
|
info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status']
|
||||||
|
save_jobs_db()
|
||||||
flash('Recurring schedule cancelled.', 'success')
|
flash('Recurring schedule cancelled.', 'success')
|
||||||
return redirect(url_for('job_detail', jobid=jobid))
|
return redirect(url_for('job_detail', jobid=jobid))
|
||||||
|
|
||||||
|
|||||||
@ -252,6 +252,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Retention Policy -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-card-header">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
Retention Policy
|
||||||
|
</div>
|
||||||
|
<div class="section-card-body">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label class="form-label" for="retention_type">Backups to keep</label>
|
||||||
|
<select id="retention_type" name="retention_type" class="form-control" onchange="onRetentionChange(this.value)">
|
||||||
|
<option value="keep_all">Keep all backups (No automatic deletion)</option>
|
||||||
|
<option value="keep_count">Keep latest N backups (Count based)</option>
|
||||||
|
<option value="keep_days">Keep backups for N days (Age based)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="retention_val_group" style="margin:0; display:none;">
|
||||||
|
<label class="form-label" for="retention_value">Number of backups (N)</label>
|
||||||
|
<input id="retention_value" class="form-control" type="number" name="retention_value" value="5" min="1" max="1000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-top:10px;" id="retention_hint">
|
||||||
|
All successful backup copies will be preserved indefinitely.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Schedule -->
|
<!-- Schedule -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-card-header">
|
<div class="section-card-header">
|
||||||
@ -447,6 +474,29 @@
|
|||||||
wrap.style.display = '';
|
wrap.style.display = '';
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
function onRetentionChange(val) {
|
||||||
|
const valGroup = document.getElementById('retention_val_group');
|
||||||
|
const hint = document.getElementById('retention_hint');
|
||||||
|
const input = document.getElementById('retention_value');
|
||||||
|
|
||||||
|
if (val === 'keep_all') {
|
||||||
|
valGroup.style.display = 'none';
|
||||||
|
hint.textContent = 'All successful backup copies will be preserved indefinitely.';
|
||||||
|
} else if (val === 'keep_count') {
|
||||||
|
valGroup.style.display = '';
|
||||||
|
input.min = '1';
|
||||||
|
input.value = input.value || '5';
|
||||||
|
document.querySelector('label[for="retention_value"]').textContent = 'Number of backups (N)';
|
||||||
|
hint.textContent = 'Only the latest N successful backups will be kept. Older ones will be deleted automatically.';
|
||||||
|
} else if (val === 'keep_days') {
|
||||||
|
valGroup.style.display = '';
|
||||||
|
input.min = '1';
|
||||||
|
input.value = input.value || '7';
|
||||||
|
document.querySelector('label[for="retention_value"]').textContent = 'Number of days (N)';
|
||||||
|
hint.textContent = 'Backups older than N days will be deleted automatically.';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@ -232,6 +232,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Retention Policy -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-card-header">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
Retention Policy
|
||||||
|
</div>
|
||||||
|
<div class="section-card-body">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label class="form-label" for="retention_type">Backups to keep</label>
|
||||||
|
<select id="retention_type" name="retention_type" class="form-control" onchange="onRetentionChange(this.value)">
|
||||||
|
<option value="keep_all">Keep all backups (No automatic deletion)</option>
|
||||||
|
<option value="keep_count">Keep latest N backups (Count based)</option>
|
||||||
|
<option value="keep_days">Keep backups for N days (Age based)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="retention_val_group" style="margin:0; display:none;">
|
||||||
|
<label class="form-label" for="retention_value">Number of backups (N)</label>
|
||||||
|
<input id="retention_value" class="form-control" type="number" name="retention_value" value="5" min="1" max="1000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-top:10px;" id="retention_hint">
|
||||||
|
All successful backup copies will be preserved indefinitely.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Schedule -->
|
<!-- Schedule -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-card-header">
|
<div class="section-card-header">
|
||||||
@ -528,5 +555,28 @@
|
|||||||
wrap.style.display = '';
|
wrap.style.display = '';
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
function onRetentionChange(val) {
|
||||||
|
const valGroup = document.getElementById('retention_val_group');
|
||||||
|
const hint = document.getElementById('retention_hint');
|
||||||
|
const input = document.getElementById('retention_value');
|
||||||
|
|
||||||
|
if (val === 'keep_all') {
|
||||||
|
valGroup.style.display = 'none';
|
||||||
|
hint.textContent = 'All successful backup copies will be preserved indefinitely.';
|
||||||
|
} else if (val === 'keep_count') {
|
||||||
|
valGroup.style.display = '';
|
||||||
|
input.min = '1';
|
||||||
|
input.value = input.value || '5';
|
||||||
|
document.querySelector('label[for="retention_value"]').textContent = 'Number of backups (N)';
|
||||||
|
hint.textContent = 'Only the latest N successful backups will be kept. Older ones will be deleted automatically.';
|
||||||
|
} else if (val === 'keep_days') {
|
||||||
|
valGroup.style.display = '';
|
||||||
|
input.min = '1';
|
||||||
|
input.value = input.value || '7';
|
||||||
|
document.querySelector('label[for="retention_value"]').textContent = 'Number of days (N)';
|
||||||
|
hint.textContent = 'Backups older than N days will be deleted automatically.';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -178,8 +178,26 @@
|
|||||||
<div class="detail-item-val mono" style="font-size:13px;">{{ job.started_fmt }}</div>
|
<div class="detail-item-val mono" style="font-size:13px;">{{ job.started_fmt }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="detail-item-label">Destination</div>
|
<div class="detail-item-label">Backup Location</div>
|
||||||
<div class="detail-item-val mono" style="font-size:12px; word-break:break-all;">{{ job.dest or '—' }}</div>
|
<div class="detail-item-val mono" style="font-size:12px; word-break:break-all;">
|
||||||
|
{% if job.run_dest %}
|
||||||
|
{{ job.run_dest }}
|
||||||
|
{% else %}
|
||||||
|
{{ job.dest or '—' }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-item-label">Retention Policy</div>
|
||||||
|
<div class="detail-item-val" style="font-size:13px;">
|
||||||
|
{% if job.retention_type == 'keep_count' %}
|
||||||
|
Keep latest {{ job.retention_value }} successful backups
|
||||||
|
{% elif job.retention_type == 'keep_days' %}
|
||||||
|
Keep backups for {{ job.retention_value }} days
|
||||||
|
{% else %}
|
||||||
|
Keep all backups (No cleanup)
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="detail-item-label">Options</div>
|
<div class="detail-item-label">Options</div>
|
||||||
|
|||||||
@ -146,13 +146,22 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if job.schedule_type and job.schedule_type != 'now' %}
|
{% if job.schedule_type and job.schedule_type != 'now' %}
|
||||||
<span class="schedule-tag">
|
<span class="schedule-tag" style="margin-bottom: 4px;">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
{{ job.schedule_type|capitalize }}
|
{{ job.schedule_type|capitalize }}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted text-small">One-time</span>
|
<span class="text-muted text-small" style="display: block; margin-bottom: 4px;">One-time</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="text-muted" style="font-size:11px; margin-top: 2px;">
|
||||||
|
{% if job.retention_type == 'keep_count' %}
|
||||||
|
Keep: {{ job.retention_value }} backups
|
||||||
|
{% elif job.retention_type == 'keep_days' %}
|
||||||
|
Keep: {{ job.retention_value }} days
|
||||||
|
{% else %}
|
||||||
|
Keep: All
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-small text-muted">
|
<td class="text-small text-muted">
|
||||||
{{ job.started_fmt }}
|
{{ job.started_fmt }}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user