From 505bd2b0f53409bb38203888205f2e33684ff5b9 Mon Sep 17 00:00:00 2001 From: Rizqi Date: Sun, 21 Jun 2026 03:47:45 +0700 Subject: [PATCH] feat: add core backup functionality and GUI scaffolding for vSphere VMs --- .../__pycache__/backup_core.cpython-310.pyc | Bin 9694 -> 11698 bytes .../__pycache__/gui_app.cpython-310.pyc | Bin 11040 -> 16791 bytes vsphere_backup/backup_core.py | 85 +++++- vsphere_backup/gui_app.py | 278 +++++++++++++++--- vsphere_backup/templates/base.html | 3 + vsphere_backup/templates/create_job.html | 35 ++- vsphere_backup/templates/job_detail.html | 233 +++++++++++---- vsphere_backup/templates/nfs.html | 211 +++++++++++++ vsphere_backup/templates/vms.html | 18 +- 9 files changed, 750 insertions(+), 113 deletions(-) create mode 100644 vsphere_backup/templates/nfs.html diff --git a/vsphere_backup/__pycache__/backup_core.cpython-310.pyc b/vsphere_backup/__pycache__/backup_core.cpython-310.pyc index 9b3a3183c5957dcb7989b3ee4eea54e1db31e086..5efcc76b409ef1fe6894013d7870358b32ebb623 100644 GIT binary patch delta 5327 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 delta 3179 zcmb7GU2q#$72dmANvqZB$NI5l%d+L4Bv$MuEg?V659P=4kd`_>b`wxqjI6u1CCid> zcWnpdE>0^$6@n)TcPLFlTbbd3;h&mehCTrg@W}8$JEI4fFv9~6%rG$FsckrCWv7|K z1FUAhd+)ht&pkT#-1FTxFaGhFU?mXHBzO+~>rnB?{#(Iba^%+NJ3=9o3A&H&rhC>& zc9@RP%(|4_Mah(u*}qjK`#dBo-thjFJhgRA{R|;bi?@Amk(#)rJwEa@Sj%|WacEjT zvz)8Wlygf)OQD9DXOkdhrx8wxd-0ffPa7apqTl~(aW4`R_x(psd=@6B^IpK6CjcGv z@l)`c8j&PRDw3kiU6fFHL$1@KO+utg7cNLtQs7s25vi;`A(@M-AjjY5;N6+s^>DiO=(OdckvgbT3E~=^ z6!!z)IGZ6Y)woHX z3AQ}TtW+w@w%pYmGmVz}(sIQz*ctJDaF=*2bU2a;Fcm`B^3uGsCG5Po68dN|s~hEf zg&K6GnzI&KF0jI$M?J#C-$RE-2B9A&V8SXMWF4V|a#7P(TT%lx` zEf?r8TTDdulI!A9BuVOGBl4_z6|^%1^zX%^;(jC=cm)Ke3=k%4UBsiW2Ts6%&7+>e zR>Uu(UGd8}kh6Yj&Y#CYr^nHFD-ExE73 zO4irjL3Caq#)kz+TDqkGpge~y9NE0%<}AOB9b^xw#4bgG4jgR^~4LL z_nR=sUPX8kpr$=n#Y`xa=hN(>PGhXUL^_9ns)5imy>hM)Zi;im{c zLHIF1R^5pk+d^3w;rj?304ssOtPUs8T2>FriA6MC^ubzisdeP=Ai&@y6gaa z736`U7fPhx4)IV?<*vmwnT_(09fT@5b}7vRO&?ZV9yE_y8dPi-R_xbcuU;b5ZTT&| zsc%Ykf>qlNQ;*$I`?D3`K^}%`?t!1W;f5WN?G7u*{dHnTc*ML2mV9Wz3h^MwqE^^x zV?0WwH7b2s?w0I0kFhF`H#>L?OZL2Eck(!RderW+BD||ko|o-}-EBp=&J%Un?%_S= zbug^)#0M_&o}y|ed3Q5L{Z_o#xhdPd&~`NwT;U1c3sFc}-4L@RjJkP>NBEjYm#Dts zh5y_Mi>DmEuweN<-U-V`?SAfNzp{FGKX{(zeV~(ebhey+|90QY?s3)p9XJ%gf6Dvd z+mPLFCApUat8tC@JC5`sgVj9{?cZA|3ei|Y`r#cn3EN}!@gNORASru*51@n(6#Jc- zH1>EH{`Vjs1j$g%V-4^@WTgvId0vq$?I!u+Ad&1LBGtlRI?RV~ldQqw5YQsf*8)abU^jw9dDGW`k!l2^a1zq5{3N*FI z)Wr0e^s)(B%(%1+rI3ogk$yL8z^phiGB0O#i9d`S&b3^Hvc*o~GE)dw5b6l)2nP^e zM!>sO%gc;v36^Kb6D^l%RNG{s6f1=?vrrF-l_58zU4lVdo<8 z|CccqLK{aBjv+XtPgdAj7-YkFX5^sO&+HH=D~o}>gVSNUec6Gj?fU_cc-9My?oe9^ zEnPrZL^D1!H)l8$lhsOS(m(dJ-L3)WMi+4s3De_g1 zCOXEtXB-kB6R^V{hvk4Do`Gfp#?u+^#Ka^%ncXc9gyC|*YI)}ktC}-SXQ$W-s4^_x z>@5V`?v~!Z^6hhOd@Lt!j*Sp1ela$B@mr{K5CO@6J%xZbly-(-BDA;qL%HIlNqeVJ z<%2{(>TrU^!gzZMsmGTpbh%`F4DRES`Du}R?1>)Dr-oEj^{a~N1@x*hIUy76fgb%g DYsUhE diff --git a/vsphere_backup/__pycache__/gui_app.cpython-310.pyc b/vsphere_backup/__pycache__/gui_app.cpython-310.pyc index d319e55853ddc5c230f8d159c11add53acfb8084..1dcbf16a5127279d3c127de6ff8e5750fabcc152 100644 GIT binary patch literal 16791 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 diff --git a/vsphere_backup/backup_core.py b/vsphere_backup/backup_core.py index 2e2c530..035d8c2 100644 --- a/vsphere_backup/backup_core.py +++ b/vsphere_backup/backup_core.py @@ -113,19 +113,27 @@ def find_datacenter_for_datastore(content, datastore_name): return None -def download_datastore_file(host, dc_name, datastore_name, ds_path, local_path, session_cookie, verify_ssl=True): +def download_datastore_file(host, dc_name, datastore_name, ds_path, local_path, + session_cookie, verify_ssl=True, progress_cb=None): + """Download a file from a vSphere datastore. progress_cb(bytes_done, bytes_total) is optional.""" encoded_path = urllib.parse.quote(ds_path, safe='') - url = f"https://{host}/folder/{encoded_path}?dcPath={urllib.parse.quote(dc_name)}&dsName={urllib.parse.quote(datastore_name)}" + url = (f"https://{host}/folder/{encoded_path}" + f"?dcPath={urllib.parse.quote(dc_name)}&dsName={urllib.parse.quote(datastore_name)}") headers = {"Cookie": f"vmware_soap_session={session_cookie}"} print(f"Downloading {ds_path} from datastore {datastore_name} to {local_path}") with requests.get(url, headers=headers, stream=True, verify=verify_ssl) as r: r.raise_for_status() + total_bytes = int(r.headers.get('Content-Length', 0)) + done_bytes = 0 os.makedirs(os.path.dirname(local_path), exist_ok=True) with open(local_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=10 * 1024 * 1024): + for chunk in r.iter_content(chunk_size=4 * 1024 * 1024): if chunk: f.write(chunk) - print("Download completed") + done_bytes += len(chunk) + if progress_cb: + progress_cb(done_bytes, total_bytes) + print(f"Download completed ({done_bytes // (1024*1024)} MB)") def extract_session_cookie(si): @@ -221,31 +229,42 @@ def upload_via_sftp(host, user, password, key_filename, local_path, remote_dir): def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ssl=False, - sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None, log_path=None): - """Run full backup flow. If log_path is provided, stdout/stderr will be redirected there.""" + sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None, + log_path=None, progress_cb=None): + """Run full backup flow. progress_cb(phase, pct, detail) is called with live status updates.""" if log_path: logfile = open(log_path, 'ab') - # use binary logfile; redirect prints into it def _wrap(): with redirect_stdout(logfile), redirect_stderr(logfile): return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, - sftp_host, sftp_user, sftp_password, sftp_key) + sftp_host, sftp_user, sftp_password, sftp_key, + progress_cb=progress_cb) try: return _wrap() finally: logfile.close() else: return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, - sftp_host, sftp_user, sftp_password, sftp_key) + sftp_host, sftp_user, sftp_password, sftp_key, + progress_cb=progress_cb) def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, - sftp_host, sftp_user, sftp_password, sftp_key): + sftp_host, sftp_user, sftp_password, sftp_key, progress_cb=None): + def _prog(phase, pct, detail=''): + if progress_cb: + try: + progress_cb({'phase': phase, 'pct': pct, 'detail': detail}) + except Exception: + pass + si = None try: + _prog('connecting', 0, 'Connecting to vCenter…') si = get_si(host, user, password, no_verify_ssl=no_verify_ssl) content = si.RetrieveContent() - # find vm + + _prog('connecting', 2, f'Looking up VM: {vm_name}') obj_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True) vm = None for v in obj_view.view: @@ -259,8 +278,10 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss snap_name = f"backup-{int(time.time())}" created_snapshot = False try: + _prog('snapshot', 3, 'Creating snapshot…') create_snapshot(vm, snap_name, desc="Automated backup snapshot", memory=False, quiesce=False) created_snapshot = True + _prog('snapshot', 5, 'Snapshot created') session_cookie = extract_session_cookie(si) if not session_cookie: @@ -272,8 +293,14 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss if vmx_ref: all_refs.append(vmx_ref) + total_files = len(all_refs) + # Download phase: 5% -> 90% + DOWNLOAD_START = 5 + DOWNLOAD_END = 90 + download_range = DOWNLOAD_END - DOWNLOAD_START + downloaded_files = [] - for ref in all_refs: + for file_idx, ref in enumerate(all_refs): ds_name, ds_path = parse_datastore_path(ref) dc = find_datacenter_for_datastore(content, ds_name) if not dc: @@ -281,12 +308,41 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss dc_name = dc.name safe_path = ds_path.replace('/', os.sep) local_file = os.path.join(dest, ds_name, safe_path) - download_datastore_file(host, dc_name, ds_name, ds_path, local_file, session_cookie, verify_ssl=not no_verify_ssl) + + file_base_pct = DOWNLOAD_START + int((file_idx / total_files) * download_range) + file_share = download_range / total_files + + def make_dl_cb(fidx, total, base_pct, share, fname): + def _dl_cb(done, total_b): + if total_b > 0: + file_pct = done / total_b + overall_pct = int(base_pct + file_pct * share) + done_mb = done // (1024 * 1024) + total_mb = total_b // (1024 * 1024) + detail = (f'File {fidx+1}/{total}: {fname} — ' + f'{done_mb} / {total_mb} MB ' + f'({int(file_pct*100)}%)') + else: + overall_pct = base_pct + detail = f'File {fidx+1}/{total}: {fname}' + _prog('downloading', overall_pct, detail) + return _dl_cb + + _prog('downloading', file_base_pct, + f'Starting file {file_idx+1}/{total_files}: {os.path.basename(ds_path)}') + download_datastore_file( + host, dc_name, ds_name, ds_path, local_file, session_cookie, + verify_ssl=not no_verify_ssl, + progress_cb=make_dl_cb(file_idx, total_files, file_base_pct, + file_share, os.path.basename(ds_path)) + ) downloaded_files.append(local_file) + _prog('compressing', 90, 'Download complete') final_files = [] for f in downloaded_files: if compress: + _prog('compressing', 92, f'Compressing {os.path.basename(f)}…') cf = maybe_compress(f) final_files.append(cf) else: @@ -295,9 +351,11 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss if sftp_host: if not sftp_user: raise Exception('SFTP user required') + _prog('uploading', 95, f'Uploading to {sftp_host}…') for f in final_files: upload_via_sftp(sftp_host, sftp_user, sftp_password, sftp_key, f, os.path.basename(dest)) + _prog('cleanup', 97, 'Removing snapshot…') print('Backup completed successfully') finally: if created_snapshot: @@ -311,6 +369,7 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss print(f'Failed to remove snapshot: {e}', file=sys.stderr) else: print('Snapshot object not found in tree; may have been removed already') + _prog('done', 100, 'Backup finished successfully') finally: if si: try: diff --git a/vsphere_backup/gui_app.py b/vsphere_backup/gui_app.py index b9c3737..170de2a 100644 --- a/vsphere_backup/gui_app.py +++ b/vsphere_backup/gui_app.py @@ -7,6 +7,8 @@ import sys import uuid import threading import time +import platform +import subprocess import json from datetime import datetime from functools import wraps @@ -19,6 +21,8 @@ from flask import ( from backup_core import run_backup, list_vms +IS_LINUX = platform.system() == 'Linux' + # ── APScheduler (optional graceful degradation) ────────────────────────────── try: from apscheduler.schedulers.background import BackgroundScheduler @@ -50,6 +54,137 @@ if HAS_SCHEDULER: scheduler = BackgroundScheduler(daemon=True) scheduler.start() +# ── VM list cache ───────────────────────────────────────────────────────────── +# Keyed by (host, user) so different users get separate caches. +_vm_cache: dict = {} # key -> {'vms': [...], 'ts': float, 'error': str|None} +_vm_cache_lock = threading.Lock() +VM_CACHE_TTL = 60 # seconds before background refresh + + +def _cache_key(host, user): + return f'{host}::{user}' + + +def get_cached_vms(host, user, password, no_verify_ssl=False, force=False): + """ + Return VM list from cache. If cache is missing or expired, fetch synchronously. + A background thread keeps the cache warm after the first fetch. + """ + key = _cache_key(host, user) + with _vm_cache_lock: + entry = _vm_cache.get(key) + + now = time.time() + if not force and entry and (now - entry['ts']) < VM_CACHE_TTL: + # Fresh cache — return immediately + return entry['vms'], entry['error'], entry['ts'] + + if not force and entry: + # Stale but exists — return stale data and kick off background refresh + _start_bg_refresh(host, user, password, no_verify_ssl) + return entry['vms'], entry['error'], entry['ts'] + + # No cache at all — must fetch synchronously (first load or forced refresh) + return _fetch_and_cache(host, user, password, no_verify_ssl) + + +def _fetch_and_cache(host, user, password, no_verify_ssl): + """Fetch VM list from vSphere and store in cache. Returns (vms, error, ts).""" + key = _cache_key(host, user) + try: + vms = list_vms(host, user, password, no_verify_ssl=no_verify_ssl) + order = {'poweredOn': 0, 'suspended': 1, 'poweredOff': 2} + vms.sort(key=lambda v: (order.get(v['power_state'], 3), v['name'].lower())) + entry = {'vms': vms, 'ts': time.time(), 'error': None} + except Exception as e: + # Keep old VM list on error, just update error message + with _vm_cache_lock: + old = _vm_cache.get(key, {}) + entry = {'vms': old.get('vms', []), 'ts': time.time(), 'error': str(e)} + with _vm_cache_lock: + _vm_cache[key] = entry + return entry['vms'], entry['error'], entry['ts'] + + +_bg_refresh_running: set = set() + + +def _start_bg_refresh(host, user, password, no_verify_ssl): + """Kick off a background thread to refresh the cache if not already running.""" + key = _cache_key(host, user) + if key in _bg_refresh_running: + return + _bg_refresh_running.add(key) + + def _worker(): + try: + _fetch_and_cache(host, user, password, no_verify_ssl) + finally: + _bg_refresh_running.discard(key) + + t = threading.Thread(target=_worker, daemon=True) + t.start() + + +# ── NFS management (Linux only) ───────────────────────────────────────────────── + +def list_nfs_mounts(): + """Return list of currently mounted NFS/CIFS shares from /proc/mounts.""" + mounts = [] + if not IS_LINUX: + return mounts + try: + with open('/proc/mounts', 'r') as f: + for line in f: + parts = line.strip().split() + if len(parts) >= 4 and parts[2] in ('nfs', 'nfs4', 'cifs'): + info = {'device': parts[0], 'mountpoint': parts[1], + 'fstype': parts[2], 'options': parts[3]} + # Add disk space info + try: + st = os.statvfs(parts[1]) + total = st.f_blocks * st.f_frsize + free = st.f_available * st.f_frsize + used = total - free + info['total_gb'] = round(total / (1024**3), 1) + info['used_gb'] = round(used / (1024**3), 1) + info['free_gb'] = round(free / (1024**3), 1) + info['pct_used'] = int(used / total * 100) if total > 0 else 0 + except Exception: + info.update({'total_gb': 0, 'used_gb': 0, 'free_gb': 0, 'pct_used': 0}) + mounts.append(info) + except (FileNotFoundError, PermissionError): + pass + return mounts + + +def mount_nfs(server, export, mountpoint, nfs_version='4', extra_opts=''): + """Mount an NFS share (Linux only).""" + if not IS_LINUX: + raise RuntimeError('NFS mounting is only supported on Linux') + os.makedirs(mountpoint, exist_ok=True) + opts = [] + if nfs_version: + opts.append(f'vers={nfs_version}') + if extra_opts: + opts.append(extra_opts.strip()) + cmd = ['mount', '-t', 'nfs'] + if opts: + cmd += ['-o', ','.join(opts)] + cmd += [f'{server}:{export}', mountpoint] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + raise RuntimeError((result.stderr or result.stdout or 'mount failed').strip()) + + +def umount_nfs(mountpoint): + """Unmount an NFS share (Linux only).""" + if not IS_LINUX: + raise RuntimeError('NFS unmounting is only supported on Linux') + result = subprocess.run(['umount', mountpoint], capture_output=True, text=True, timeout=30) + if result.returncode != 0: + raise RuntimeError((result.stderr or result.stdout or 'umount failed').strip()) + # ── Helpers ─────────────────────────────────────────────────────────────────── def login_required(f): @@ -88,9 +223,14 @@ def run_job_thread(jid): info = jobs.get(jid) if not info: return - info['status'] = 'running' - info['started'] = time.time() + info['status'] = 'running' + info['started'] = time.time() + info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'} log_path = str(JOBS_DIR / jid / 'backup.log') + + def progress_cb(prog): + info['progress'] = prog + try: run_backup( host=info['host'], @@ -105,8 +245,10 @@ def run_job_thread(jid): sftp_password=info.get('sftp_password') or None, sftp_key=None, log_path=log_path, + progress_cb=progress_cb, ) - info['status'] = 'finished' + info['status'] = 'finished' + info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'} except Exception as e: info['status'] = f'failed ({e})' @@ -200,27 +342,26 @@ def index(): @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': - host = request.form.get('host', '').strip() - user = request.form.get('user', '').strip() - password = request.form.get('password', '') + host = request.form.get('host', '').strip() + user = request.form.get('user', '').strip() + password = request.form.get('password', '') no_verify_ssl = 'no_verify_ssl' in request.form if not (host and user and password): flash('Host, username and password are required.', 'danger') return render_template('login.html') - # Verify credentials by listing VMs - try: - list_vms(host, user, password, no_verify_ssl=no_verify_ssl) - except Exception as e: - flash(f'Connection failed: {e}', 'danger') + # Verify credentials — also warms the VM cache for instant /vms load + vm_list, error, _ = _fetch_and_cache(host, user, password, no_verify_ssl) + if error and not vm_list: + flash(f'Connection failed: {error}', 'danger') return render_template('login.html') session['host'] = host session['user'] = user session['password'] = password session['no_verify_ssl'] = no_verify_ssl - flash(f'Connected to {host} successfully.', 'success') + flash(f'Connected to {host} — {len(vm_list)} VMs found.', 'success') return redirect(url_for('vms')) return render_template('login.html') @@ -238,32 +379,28 @@ def logout(): @app.route('/vms') @login_required def vms(): - error = None - vm_list = [] - try: - vm_list = list_vms( - session['host'], session['user'], session['password'], - no_verify_ssl=session.get('no_verify_ssl', False) - ) - # Sort: powered on first, then alphabetical - order = {'poweredOn': 0, 'suspended': 1, 'poweredOff': 2} - vm_list.sort(key=lambda v: (order.get(v['power_state'], 3), v['name'].lower())) - except Exception as e: - error = str(e) - return render_template('vms.html', vms=vm_list, error=error) + force = request.args.get('refresh') == '1' + vm_list, error, cache_ts = get_cached_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False), + force=force, + ) + cache_age = int(time.time() - cache_ts) if cache_ts else None + return render_template('vms.html', vms=vm_list, error=error, cache_age=cache_age) @app.route('/api/vms') @login_required def api_vms(): - try: - vm_list = list_vms( - session['host'], session['user'], session['password'], - no_verify_ssl=session.get('no_verify_ssl', False) - ) - return jsonify(vm_list) - except Exception as e: - return jsonify({'error': str(e)}), 500 + force = request.args.get('refresh') == '1' + vm_list, error, cache_ts = get_cached_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False), + force=force, + ) + if error and not vm_list: + return jsonify({'error': error}), 500 + return jsonify({'vms': vm_list, 'cache_age': int(time.time() - cache_ts) if cache_ts else None}) # ── Create Job ──────────────────────────────────────────────────────────────── @@ -316,17 +453,16 @@ def create_job(): return redirect(url_for('job_detail', jobid=jid)) # GET: load VM list for the dropdown - selected_vm = request.args.get('vm', '') + selected_vm = request.args.get('vm', '') show_schedule = bool(request.args.get('schedule', '')) - vm_list = [] - try: - vm_list = list_vms( - session['host'], session['user'], session['password'], - no_verify_ssl=session.get('no_verify_ssl', False) - ) - vm_list.sort(key=lambda v: v['name'].lower()) - except Exception as e: - flash(f'Could not load VM list: {e}', 'danger') + vm_list, error, _ = get_cached_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False) + ) + if error and not vm_list: + flash(f'Could not load VM list: {error}', 'danger') + # Sort alphabetically for the dropdown + vm_list = sorted(vm_list, key=lambda v: v['name'].lower()) return render_template( 'create_job.html', @@ -380,7 +516,11 @@ def api_job_status(jobid): info = jobs.get(jobid) if not info: return jsonify({'error': 'not found'}), 404 - return jsonify({'status': info.get('status', 'unknown'), 'id': jobid}) + return jsonify({ + 'status': info.get('status', 'unknown'), + 'id': jobid, + 'progress': info.get('progress', {'pct': 0, 'phase': '', 'detail': ''}), + }) @app.route('/job//cancel-schedule', methods=['POST']) @@ -407,6 +547,56 @@ def startswith_filter(value, prefix): return str(value).startswith(prefix) +# ── NFS Management Routes ───────────────────────────────────────────────────────── + +@app.route('/nfs') +@login_required +def nfs_manager(): + mounts = list_nfs_mounts() + return render_template('nfs.html', mounts=mounts, is_linux=IS_LINUX) + + +@app.route('/nfs/mount', methods=['POST']) +@login_required +def nfs_mount(): + server = request.form.get('server', '').strip() + export = request.form.get('export', '').strip() + mountpoint = request.form.get('mountpoint', '').strip() + nfs_ver = request.form.get('nfs_version', '4') + extra_opts = request.form.get('extra_opts', '').strip() + + if not (server and export and mountpoint): + flash('Server, export path, and mount point are required.', 'danger') + return redirect(url_for('nfs_manager')) + try: + mount_nfs(server, export, mountpoint, nfs_version=nfs_ver, extra_opts=extra_opts) + flash(f'Mounted {server}:{export} → {mountpoint} successfully.', 'success') + except Exception as e: + flash(f'Mount failed: {e}', 'danger') + return redirect(url_for('nfs_manager')) + + +@app.route('/nfs/umount', methods=['POST']) +@login_required +def nfs_umount(): + mountpoint = request.form.get('mountpoint', '').strip() + if not mountpoint: + flash('Mount point is required.', 'danger') + return redirect(url_for('nfs_manager')) + try: + umount_nfs(mountpoint) + flash(f'Unmounted {mountpoint} successfully.', 'success') + except Exception as e: + flash(f'Unmount failed: {e}', 'danger') + return redirect(url_for('nfs_manager')) + + +@app.route('/api/nfs') +@login_required +def api_nfs(): + return jsonify(list_nfs_mounts()) + + # ── Main ────────────────────────────────────────────────────────────────────── if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/vsphere_backup/templates/base.html b/vsphere_backup/templates/base.html index d3b5da3..3bdf656 100644 --- a/vsphere_backup/templates/base.html +++ b/vsphere_backup/templates/base.html @@ -299,6 +299,9 @@ Create Job + + 📡 NFS Manager +