From c4447eae5e7eb8f8e66cf1d2815e092e7b10a61e Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 13 Jun 2026 19:05:00 +0200 Subject: [PATCH] update 1.2.2.2 beta --- AppImage/components/host-backup.tsx | 109 +++++++++++++-- AppImage/scripts/flask_server.py | 206 +++++++++++++++++++++++----- 2 files changed, 265 insertions(+), 50 deletions(-) diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 14fea693..17fdd6e3 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -29,10 +29,14 @@ import { formatStorage } from "../lib/utils" interface BackupJob { id: string destination: string - method: string // "local_tar" | "pbs" | "borg" | "unknown" - on_calendar: string - retention: string - timer_enabled: boolean + method: string // "pbs" | "borg" | "local" | "unknown" + on_calendar: string // "OnCalendar=..." for timer jobs, "attached → storage:X" for attached + retention: string // "last=5, daily=7, ..." + timer_enabled: boolean // legacy — only meaningful for non-attached jobs + enabled: boolean // unified state (timer for non-attached, ENABLED= for attached) + attached: boolean // PVE vzdump-attached: no own timer, trigger from hook + pve_storage: string | null // storage id the attached job listens for + profile_mode: string // "default" | "custom" last_status: string | null next_run: string | null } @@ -133,11 +137,41 @@ const formatNext = (iso: string | null) => { } export function HostBackup() { - const { data: jobsResp, error: jobsErr } = useSWR<{ jobs: BackupJob[] }>( + const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>( "/api/host-backups/jobs", fetcher, { refreshInterval: 30000 }, ) + const [busyJobId, setBusyJobId] = useState(null) + const [actionError, setActionError] = useState(null) + + async function runJob(id: string) { + setBusyJobId(id) + setActionError(null) + try { + const r = await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}/run`, { method: "POST" }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + mutateJobs() + } catch (e) { + setActionError(`Failed to run "${id}": ${e instanceof Error ? e.message : String(e)}`) + } finally { + setBusyJobId(null) + } + } + + async function toggleJob(id: string) { + setBusyJobId(id) + setActionError(null) + try { + const r = await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}/toggle`, { method: "POST" }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + mutateJobs() + } catch (e) { + setActionError(`Failed to toggle "${id}": ${e instanceof Error ? e.message : String(e)}`) + } finally { + setBusyJobId(null) + } + } const { data: archivesResp, error: archivesErr } = useSWR<{ archives: BackupArchive[] }>( "/api/host-backups/archives", fetcher, @@ -181,6 +215,11 @@ export function HostBackup() { ) : (
+ {actionError && ( +
+ {actionError} +
+ )} @@ -190,17 +229,27 @@ export function HostBackup() { + - {jobsResp.jobs.map((j) => ( + {jobsResp.jobs.map((j) => { + const isBusy = busyJobId === j.id + return ( - - + + + - ))} + )})}
Schedule Last status Next runActions
{j.id} {j.destination || "—"} {j.method}{j.on_calendar}{j.method} + {j.on_calendar} + {j.attached && ( + + attached + + )} + {j.last_status ? ( {j.last_status} @@ -209,15 +258,51 @@ export function HostBackup() { )} - {formatNext(j.next_run)} - {!j.timer_enabled && ( + {j.attached ? "—" : formatNext(j.next_run)} + {!j.enabled && ( - timer disabled + disabled )} +
+ + +
+
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 94320789..0d29108b 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -12376,39 +12376,62 @@ def _find_backup_archive_path(archive_id): return None -@app.route('/api/host-backups/jobs', methods=['GET']) -@require_auth -def api_host_backups_jobs(): - """List scheduled host-backup jobs created via the backup_scheduler - CLI. Each job has a .env file + systemd timer. We report on both, - plus the last-run status when available.""" - import glob - jobs: list = [] - try: - env_files = sorted(glob.glob(f'{_BACKUP_JOBS_DIR}/*.env')) - except OSError: - env_files = [] +_JOB_ID_RE = re.compile(r'^[a-zA-Z0-9_-]+$') +_BACKUP_RUNNER = '/usr/local/share/proxmenux/scripts/backup_restore/run_scheduled_backup.sh' - for env_file in env_files: - job_id = os.path.basename(env_file)[:-len('.env')] - job = _parse_job_env(env_file) +def _backup_job_summary(env_file: str) -> dict: + """Build the UI summary for one job .env, reading the actual fields + produced by the current scheduler (BACKEND, LOCAL_DEST_DIR, + PBS_REPOSITORY, BORG_REPO, KEEP_*, PVE_STORAGE, ENABLED).""" + job_id = os.path.basename(env_file)[:-len('.env')] + job = _parse_job_env(env_file) + + backend = (job.get('BACKEND') or job.get('METHOD') or 'unknown').lower() + attached = bool(job.get('PVE_STORAGE')) + pve_storage = job.get('PVE_STORAGE') or None + + if backend == 'pbs': + destination = job.get('PBS_REPOSITORY') or '' + bid = job.get('PBS_BACKUP_ID') + if bid: + destination = f'{destination} :: host/{bid}' + elif backend == 'local': + destination = (job.get('LOCAL_DEST_DIR') or job.get('DEST_DIR') + or job.get('DEST') or '') + elif backend == 'borg': + destination = job.get('BORG_REPO') or '' + else: + destination = (job.get('DEST_DIR') or job.get('DEST') + or job.get('PBS_REPO') or job.get('BORG_REPO') or '') + + keep_parts = [] + for k_key, label in ( + ('KEEP_LAST', 'last'), + ('KEEP_HOURLY', 'hourly'), + ('KEEP_DAILY', 'daily'), + ('KEEP_WEEKLY', 'weekly'), + ('KEEP_MONTHLY', 'monthly'), + ('KEEP_YEARLY', 'yearly'), + ): + v = job.get(k_key) + if v and v != '0': + keep_parts.append(f'{label}={v}') + retention = ', '.join(keep_parts) or job.get('RETENTION') or '' + + if attached: + enabled = (job.get('ENABLED', '1') == '1') + schedule = f'attached → storage:{pve_storage}' if pve_storage else 'attached' + timer_enabled = False + next_run = None + else: timer_unit = f'proxmenux-backup-{job_id}.timer' timer_enabled = subprocess.run( ['systemctl', 'is-enabled', '--quiet', timer_unit], capture_output=True ).returncode == 0 - - last_status = None - last_status_file = f'{_BACKUP_LOG_DIR}/{job_id}-last.status' - if os.path.exists(last_status_file): - try: - with open(last_status_file) as f: - last_status = f.read().strip() - except OSError: - pass - - # Next scheduled run from systemctl list-timers + enabled = timer_enabled + schedule = job.get('ON_CALENDAR') or 'manual' next_run = None try: r = subprocess.run( @@ -12422,18 +12445,125 @@ def api_host_backups_jobs(): except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError, OSError): pass - jobs.append({ - 'id': job_id, - 'destination': (job.get('DEST_DIR') or job.get('DEST') - or job.get('PBS_REPO') or job.get('BORG_REPO') or ''), - 'method': job.get('METHOD') or 'unknown', - 'on_calendar': job.get('ON_CALENDAR') or 'manual', - 'retention': job.get('RETENTION') or '', - 'timer_enabled': timer_enabled, - 'last_status': last_status, - 'next_run': next_run, - }) - return jsonify({'jobs': jobs}) + last_status = None + last_status_file = f'{_BACKUP_LOG_DIR}/{job_id}-last.status' + if os.path.exists(last_status_file): + try: + with open(last_status_file) as f: + last_status = f.read().strip() + except OSError: + pass + + return { + 'id': job_id, + 'destination': destination, + 'method': backend, + 'on_calendar': schedule, + 'retention': retention, + 'timer_enabled': timer_enabled, + 'enabled': enabled, + 'attached': attached, + 'pve_storage': pve_storage, + 'profile_mode': job.get('PROFILE_MODE') or 'default', + 'last_status': last_status, + 'next_run': next_run, + } + + +@app.route('/api/host-backups/jobs', methods=['GET']) +@require_auth +def api_host_backups_jobs(): + """List scheduled host-backup jobs created via the backup_scheduler + CLI. Reports both timer-based and PVE-attached jobs.""" + import glob + try: + env_files = sorted(glob.glob(f'{_BACKUP_JOBS_DIR}/*.env')) + except OSError: + env_files = [] + return jsonify({'jobs': [_backup_job_summary(f) for f in env_files]}) + + +@app.route('/api/host-backups/jobs//run', methods=['POST']) +@require_auth +def api_host_backups_job_run(job_id): + """Trigger a scheduled host backup job immediately (background). + The runner writes its own log + .status file; the UI polls /jobs to + pick up the new status.""" + if not _JOB_ID_RE.match(job_id): + return jsonify({'error': 'invalid job id'}), 400 + env_file = f'{_BACKUP_JOBS_DIR}/{job_id}.env' + if not os.path.exists(env_file): + return jsonify({'error': 'job not found'}), 404 + if not os.path.exists(_BACKUP_RUNNER): + return jsonify({'error': 'runner script not installed'}), 500 + try: + subprocess.Popen( + ['bash', _BACKUP_RUNNER, job_id], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + close_fds=True, + start_new_session=True, + ) + except OSError as e: + return jsonify({'error': f'failed to start: {e}'}), 500 + return jsonify({'status': 'started', 'job_id': job_id}), 202 + + +@app.route('/api/host-backups/jobs//toggle', methods=['POST']) +@require_auth +def api_host_backups_job_toggle(job_id): + """Flip enabled/disabled. Attached jobs are toggled by rewriting + ENABLED= in the .env (no timer exists). Timer-based jobs use + systemctl --now enable/disable. Body may include {"enabled": bool} + to set an explicit target; otherwise the current state is inverted.""" + if not _JOB_ID_RE.match(job_id): + return jsonify({'error': 'invalid job id'}), 400 + env_file = f'{_BACKUP_JOBS_DIR}/{job_id}.env' + if not os.path.exists(env_file): + return jsonify({'error': 'job not found'}), 404 + + job = _parse_job_env(env_file) + is_attached = bool(job.get('PVE_STORAGE')) + + payload = request.get_json(silent=True) or {} + target = payload.get('enabled') + + if is_attached: + current = (job.get('ENABLED', '1') == '1') + new_state = (not current) if target is None else bool(target) + new_val = '1' if new_state else '0' + try: + with open(env_file, 'r') as f: + lines = f.readlines() + tmp_path = f'{env_file}.tmp.{os.getpid()}' + with open(tmp_path, 'w') as tmp: + found = False + for line in lines: + if line.startswith('ENABLED='): + tmp.write(f'ENABLED={new_val}\n') + found = True + else: + tmp.write(line) + if not found: + tmp.write(f'ENABLED={new_val}\n') + os.replace(tmp_path, env_file) + os.chmod(env_file, 0o600) + except OSError as e: + return jsonify({'error': f'could not update .env: {e}'}), 500 + return jsonify({'status': 'ok', 'enabled': new_state, 'attached': True}) + + timer_unit = f'proxmenux-backup-{job_id}.timer' + current = subprocess.run( + ['systemctl', 'is-enabled', '--quiet', timer_unit], + capture_output=True + ).returncode == 0 + new_state = (not current) if target is None else bool(target) + cmd = ['systemctl', '--now', 'enable' if new_state else 'disable', timer_unit] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if r.returncode != 0: + return jsonify({'error': f'systemctl failed: {r.stderr.strip()}'}), 500 + return jsonify({'status': 'ok', 'enabled': new_state, 'attached': False}) _BACKUP_TAR_SUFFIXES = ('.tar', '.tar.zst', '.tar.gz')