feat: add core backup functionality and GUI scaffolding for vSphere VMs

This commit is contained in:
Rizqi 2026-06-21 03:47:45 +07:00
parent 474192b186
commit 505bd2b0f5
9 changed files with 750 additions and 113 deletions

View File

@ -113,19 +113,27 @@ def find_datacenter_for_datastore(content, datastore_name):
return None 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='') 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}"} headers = {"Cookie": f"vmware_soap_session={session_cookie}"}
print(f"Downloading {ds_path} from datastore {datastore_name} to {local_path}") 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: with requests.get(url, headers=headers, stream=True, verify=verify_ssl) as r:
r.raise_for_status() 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) os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, 'wb') as f: 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: if chunk:
f.write(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): 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, 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): sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None,
"""Run full backup flow. If log_path is provided, stdout/stderr will be redirected there.""" log_path=None, progress_cb=None):
"""Run full backup flow. progress_cb(phase, pct, detail) is called with live status updates."""
if log_path: if log_path:
logfile = open(log_path, 'ab') logfile = open(log_path, 'ab')
# use binary logfile; redirect prints into it
def _wrap(): def _wrap():
with redirect_stdout(logfile), redirect_stderr(logfile): with redirect_stdout(logfile), redirect_stderr(logfile):
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, 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: try:
return _wrap() return _wrap()
finally: finally:
logfile.close() logfile.close()
else: else:
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, 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, 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 si = None
try: try:
_prog('connecting', 0, 'Connecting to vCenter…')
si = get_si(host, user, password, no_verify_ssl=no_verify_ssl) si = get_si(host, user, password, no_verify_ssl=no_verify_ssl)
content = si.RetrieveContent() content = si.RetrieveContent()
# find vm
_prog('connecting', 2, f'Looking up VM: {vm_name}')
obj_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True) obj_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True)
vm = None vm = None
for v in obj_view.view: 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())}" snap_name = f"backup-{int(time.time())}"
created_snapshot = False created_snapshot = False
try: try:
_prog('snapshot', 3, 'Creating snapshot…')
create_snapshot(vm, snap_name, desc="Automated backup snapshot", memory=False, quiesce=False) create_snapshot(vm, snap_name, desc="Automated backup snapshot", memory=False, quiesce=False)
created_snapshot = True created_snapshot = True
_prog('snapshot', 5, 'Snapshot created')
session_cookie = extract_session_cookie(si) session_cookie = extract_session_cookie(si)
if not session_cookie: 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: if vmx_ref:
all_refs.append(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 = [] downloaded_files = []
for ref in all_refs: for file_idx, ref in enumerate(all_refs):
ds_name, ds_path = parse_datastore_path(ref) ds_name, ds_path = parse_datastore_path(ref)
dc = find_datacenter_for_datastore(content, ds_name) dc = find_datacenter_for_datastore(content, ds_name)
if not dc: 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 dc_name = dc.name
safe_path = ds_path.replace('/', os.sep) safe_path = ds_path.replace('/', os.sep)
local_file = os.path.join(dest, ds_name, safe_path) 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) downloaded_files.append(local_file)
_prog('compressing', 90, 'Download complete')
final_files = [] final_files = []
for f in downloaded_files: for f in downloaded_files:
if compress: if compress:
_prog('compressing', 92, f'Compressing {os.path.basename(f)}')
cf = maybe_compress(f) cf = maybe_compress(f)
final_files.append(cf) final_files.append(cf)
else: else:
@ -295,9 +351,11 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
if sftp_host: if sftp_host:
if not sftp_user: if not sftp_user:
raise Exception('SFTP user required') raise Exception('SFTP user required')
_prog('uploading', 95, f'Uploading to {sftp_host}')
for f in final_files: for f in final_files:
upload_via_sftp(sftp_host, sftp_user, sftp_password, sftp_key, f, os.path.basename(dest)) 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') print('Backup completed successfully')
finally: finally:
if created_snapshot: 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) print(f'Failed to remove snapshot: {e}', file=sys.stderr)
else: else:
print('Snapshot object not found in tree; may have been removed already') print('Snapshot object not found in tree; may have been removed already')
_prog('done', 100, 'Backup finished successfully')
finally: finally:
if si: if si:
try: try:

View File

@ -7,6 +7,8 @@ import sys
import uuid import uuid
import threading import threading
import time import time
import platform
import subprocess
import json import json
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
@ -19,6 +21,8 @@ from flask import (
from backup_core import run_backup, list_vms from backup_core import run_backup, list_vms
IS_LINUX = platform.system() == 'Linux'
# ── APScheduler (optional graceful degradation) ────────────────────────────── # ── APScheduler (optional graceful degradation) ──────────────────────────────
try: try:
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@ -50,6 +54,137 @@ if HAS_SCHEDULER:
scheduler = BackgroundScheduler(daemon=True) scheduler = BackgroundScheduler(daemon=True)
scheduler.start() 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 ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
def login_required(f): def login_required(f):
@ -90,7 +225,12 @@ def run_job_thread(jid):
return return
info['status'] = 'running' info['status'] = 'running'
info['started'] = time.time() info['started'] = time.time()
info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'}
log_path = str(JOBS_DIR / jid / 'backup.log') log_path = str(JOBS_DIR / jid / 'backup.log')
def progress_cb(prog):
info['progress'] = prog
try: try:
run_backup( run_backup(
host=info['host'], host=info['host'],
@ -105,8 +245,10 @@ def run_job_thread(jid):
sftp_password=info.get('sftp_password') or None, sftp_password=info.get('sftp_password') or None,
sftp_key=None, sftp_key=None,
log_path=log_path, 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: except Exception as e:
info['status'] = f'failed ({e})' info['status'] = f'failed ({e})'
@ -209,18 +351,17 @@ def login():
flash('Host, username and password are required.', 'danger') flash('Host, username and password are required.', 'danger')
return render_template('login.html') return render_template('login.html')
# Verify credentials by listing VMs # Verify credentials — also warms the VM cache for instant /vms load
try: vm_list, error, _ = _fetch_and_cache(host, user, password, no_verify_ssl)
list_vms(host, user, password, no_verify_ssl=no_verify_ssl) if error and not vm_list:
except Exception as e: flash(f'Connection failed: {error}', 'danger')
flash(f'Connection failed: {e}', 'danger')
return render_template('login.html') return render_template('login.html')
session['host'] = host session['host'] = host
session['user'] = user session['user'] = user
session['password'] = password session['password'] = password
session['no_verify_ssl'] = no_verify_ssl 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 redirect(url_for('vms'))
return render_template('login.html') return render_template('login.html')
@ -238,32 +379,28 @@ def logout():
@app.route('/vms') @app.route('/vms')
@login_required @login_required
def vms(): def vms():
error = None force = request.args.get('refresh') == '1'
vm_list = [] vm_list, error, cache_ts = get_cached_vms(
try:
vm_list = list_vms(
session['host'], session['user'], session['password'], session['host'], session['user'], session['password'],
no_verify_ssl=session.get('no_verify_ssl', False) no_verify_ssl=session.get('no_verify_ssl', False),
force=force,
) )
# Sort: powered on first, then alphabetical cache_age = int(time.time() - cache_ts) if cache_ts else None
order = {'poweredOn': 0, 'suspended': 1, 'poweredOff': 2} return render_template('vms.html', vms=vm_list, error=error, cache_age=cache_age)
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)
@app.route('/api/vms') @app.route('/api/vms')
@login_required @login_required
def api_vms(): def api_vms():
try: force = request.args.get('refresh') == '1'
vm_list = list_vms( vm_list, error, cache_ts = get_cached_vms(
session['host'], session['user'], session['password'], session['host'], session['user'], session['password'],
no_verify_ssl=session.get('no_verify_ssl', False) no_verify_ssl=session.get('no_verify_ssl', False),
force=force,
) )
return jsonify(vm_list) if error and not vm_list:
except Exception as e: return jsonify({'error': error}), 500
return jsonify({'error': str(e)}), 500 return jsonify({'vms': vm_list, 'cache_age': int(time.time() - cache_ts) if cache_ts else None})
# ── Create Job ──────────────────────────────────────────────────────────────── # ── Create Job ────────────────────────────────────────────────────────────────
@ -318,15 +455,14 @@ def create_job():
# GET: load VM list for the dropdown # 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', '')) show_schedule = bool(request.args.get('schedule', ''))
vm_list = [] vm_list, error, _ = get_cached_vms(
try:
vm_list = list_vms(
session['host'], session['user'], session['password'], session['host'], session['user'], session['password'],
no_verify_ssl=session.get('no_verify_ssl', False) no_verify_ssl=session.get('no_verify_ssl', False)
) )
vm_list.sort(key=lambda v: v['name'].lower()) if error and not vm_list:
except Exception as e: flash(f'Could not load VM list: {error}', 'danger')
flash(f'Could not load VM list: {e}', 'danger') # Sort alphabetically for the dropdown
vm_list = sorted(vm_list, key=lambda v: v['name'].lower())
return render_template( return render_template(
'create_job.html', 'create_job.html',
@ -380,7 +516,11 @@ def api_job_status(jobid):
info = jobs.get(jobid) info = jobs.get(jobid)
if not info: if not info:
return jsonify({'error': 'not found'}), 404 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/<jobid>/cancel-schedule', methods=['POST']) @app.route('/job/<jobid>/cancel-schedule', methods=['POST'])
@ -407,6 +547,56 @@ def startswith_filter(value, prefix):
return str(value).startswith(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 ────────────────────────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False) app.run(host='0.0.0.0', port=5000, debug=False)

View File

@ -299,6 +299,9 @@
<a href="/jobs/create" class="nav-link {% if active_page == 'create_job' %}active{% endif %}"> <a href="/jobs/create" class="nav-link {% if active_page == 'create_job' %}active{% endif %}">
<span class="icon"></span> Create Job <span class="icon"></span> Create Job
</a> </a>
<a href="/nfs" class="nav-link {% if active_page == 'nfs' %}active{% endif %}">
<span class="icon">📡</span> NFS Manager
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">

View File

@ -143,10 +143,16 @@
<div class="section-card"> <div class="section-card">
<div class="section-card-header">📁 Destination</div> <div class="section-card-header">📁 Destination</div>
<div class="section-card-body"> <div class="section-card-body">
<!-- NFS quick-select -->
<div id="nfsTargets" style="margin-bottom:14px; display:none;">
<div class="form-label" style="margin-bottom:6px;">📡 NFS Mounts (click to use as destination)</div>
<div id="nfsMountList" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="dest">Local backup path</label> <label class="form-label" for="dest">Local backup path</label>
<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. D:\Backups or /mnt/nas/backups" required /> value="./backups" placeholder="e.g. /mnt/nfs-backup or /data/vmbackups" required />
</div> </div>
<div class="form-check"> <div class="form-check">
<input type="checkbox" id="compress" name="compress" /> <input type="checkbox" id="compress" name="compress" />
@ -320,5 +326,32 @@
{% if show_schedule %} {% if show_schedule %}
selectSchedule('daily'); selectSchedule('daily');
{% endif %} {% endif %}
// Pre-fill dest from ?dest= query param (e.g. from NFS manager "Use as Target")
const urlDest = new URLSearchParams(window.location.search).get('dest');
if (urlDest) document.getElementById('dest').value = urlDest;
// Load NFS mounts for quick-select
fetch('/api/nfs')
.then(r => r.json())
.then(mounts => {
if (!mounts || !mounts.length) return;
const wrap = document.getElementById('nfsTargets');
const list = document.getElementById('nfsMountList');
mounts.forEach(m => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-secondary btn-sm';
btn.innerHTML = `📡 ${m.mountpoint} <span style="color:var(--text-muted);font-size:11px;margin-left:4px;">${m.free_gb}GB free</span>`;
btn.onclick = () => {
document.getElementById('dest').value = m.mountpoint;
list.querySelectorAll('button').forEach(b => b.style.borderColor = '');
btn.style.borderColor = 'var(--accent)';
};
list.appendChild(btn);
});
wrap.style.display = '';
})
.catch(() => {});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -17,6 +17,62 @@
.detail-item-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: 5px; } .detail-item-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: 5px; }
.detail-item-val { font-size: 15px; font-weight: 600; } .detail-item-val { font-size: 15px; font-weight: 600; }
/* Progress card */
.progress-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 22px;
margin-bottom: 20px;
}
.progress-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.progress-title { font-size: 14px; font-weight: 600; }
.progress-pct { font-size: 22px; font-weight: 700; color: var(--accent); }
.progress-bar-wrap {
height: 10px; border-radius: 100px;
background: rgba(255,255,255,0.06);
overflow: hidden; margin-bottom: 10px;
}
.progress-bar-fill {
height: 100%; border-radius: 100px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
transition: width .5s ease;
box-shadow: 0 0 10px var(--accent-glow);
position: relative;
}
.progress-bar-fill.pulse::after {
content: '';
position: absolute; top: 0; right: 0; bottom: 0; width: 40px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3));
animation: shimmer 1.2s linear infinite;
}
@keyframes shimmer { to { transform: translateX(20px); opacity: 0; } }
.progress-bar-fill.done {
background: linear-gradient(90deg, var(--success), #16a34a);
box-shadow: 0 0 10px rgba(34,197,94,0.3);
}
.progress-bar-fill.failed {
background: linear-gradient(90deg, var(--danger), #dc2626);
}
.progress-detail {
font-size: 12.5px; color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.phase-steps {
display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap;
}
.phase-step {
padding: 3px 10px; border-radius: 100px; font-size: 11px; font-weight: 600;
background: rgba(255,255,255,0.05); color: var(--text-muted);
border: 1px solid var(--border);
}
.phase-step.active { background: rgba(124,107,255,.15); color: var(--accent); border-color: var(--accent); }
.phase-step.done { background: rgba(34,197,94,.1); color: var(--success); border-color: rgba(34,197,94,.3); }
.log-wrap { .log-wrap {
background: #0a0c10; background: #0a0c10;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -41,20 +97,8 @@
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
} }
.log-empty { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
.running-badge { animation: pulse 1.5s ease-in-out infinite; } .running-badge { animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} } @keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
.info-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 20px;
}
.back-link {
color: var(--text-secondary); text-decoration: none; font-size: 13px;
display: flex; align-items: center; gap: 5px;
}
.back-link:hover { color: var(--text-primary); }
</style> </style>
{% endblock %} {% endblock %}
@ -63,6 +107,7 @@
<div> <div>
<div class="topbar-title"> <div class="topbar-title">
{{ job.label or 'Backup Job' }} {{ job.label or 'Backup Job' }}
<span id="statusBadge">
{% if job.status == 'running' %} {% if job.status == 'running' %}
<span class="badge badge-purple running-badge" style="margin-left:10px; vertical-align:middle;">⏳ Running</span> <span class="badge badge-purple running-badge" style="margin-left:10px; vertical-align:middle;">⏳ Running</span>
{% elif job.status == 'finished' %} {% elif job.status == 'finished' %}
@ -71,7 +116,10 @@
<span class="badge badge-yellow" style="margin-left:10px; vertical-align:middle;">⏱ Queued</span> <span class="badge badge-yellow" style="margin-left:10px; vertical-align:middle;">⏱ Queued</span>
{% elif job.status.startswith('failed') %} {% elif job.status.startswith('failed') %}
<span class="badge badge-red" style="margin-left:10px; vertical-align:middle;">✕ Failed</span> <span class="badge badge-red" style="margin-left:10px; vertical-align:middle;">✕ Failed</span>
{% else %}
<span class="badge badge-gray" style="margin-left:10px; vertical-align:middle;">{{ job.status }}</span>
{% endif %} {% endif %}
</span>
</div> </div>
<div class="topbar-subtitle">Job ID: <span class="mono">{{ job.id }}</span></div> <div class="topbar-subtitle">Job ID: <span class="mono">{{ job.id }}</span></div>
</div> </div>
@ -89,7 +137,7 @@
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-item-label">Status</div> <div class="detail-item-label">Status</div>
<div class="detail-item-val">{{ job.status }}</div> <div class="detail-item-val" id="statusText">{{ job.status }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-item-label">Schedule</div> <div class="detail-item-label">Schedule</div>
@ -130,21 +178,38 @@
</div> </div>
{% endif %} {% endif %}
<!-- Log viewer --> <!-- ── Progress card ── -->
<div class="progress-card" id="progressCard">
<div class="progress-header">
<div class="progress-title">⚡ Backup Progress</div>
<div class="progress-pct" id="progressPct">0%</div>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill pulse" id="progressBar" style="width:0%"></div>
</div>
<div class="progress-detail" id="progressDetail">Waiting to start…</div>
<div class="phase-steps">
<span class="phase-step" id="phase-connecting">🔌 Connect</span>
<span class="phase-step" id="phase-snapshot">📸 Snapshot</span>
<span class="phase-step" id="phase-downloading">⬇ Download</span>
<span class="phase-step" id="phase-compressing">🗜 Compress</span>
<span class="phase-step" id="phase-uploading">📤 Upload</span>
<span class="phase-step" id="phase-cleanup">🧹 Cleanup</span>
<span class="phase-step" id="phase-done">✓ Done</span>
</div>
</div>
<!-- ── Log viewer ── -->
<div class="log-wrap"> <div class="log-wrap">
<div class="log-toolbar"> <div class="log-toolbar">
<div class="log-title">📄 Backup Log</div> <div class="log-title">📄 Backup Log</div>
<div class="flex-center gap-2"> <div class="flex-center gap-2">
{% if job.status == 'running' %} <span id="spinnerWrap" class="spinner" style="display:none;"></span>
<span class="spinner"></span> <span id="autoRefreshLabel" class="text-small text-muted" style="display:none;">Live</span>
<span class="text-small text-muted">Auto-refreshing…</span>
{% endif %}
<button class="btn btn-ghost btn-sm" onclick="scrollLogBottom()">⬇ Bottom</button> <button class="btn btn-ghost btn-sm" onclick="scrollLogBottom()">⬇ Bottom</button>
</div> </div>
</div> </div>
<pre id="logContent"> <pre id="logContent">(Loading…)</pre>
<div class="log-empty">Loading log…</div>
</pre>
</div> </div>
</div> </div>
@ -153,17 +218,57 @@
{% block scripts %} {% block scripts %}
<script> <script>
const jobId = {{ job.id | tojson }}; const jobId = {{ job.id | tojson }};
const status = {{ job.status | tojson }}; const initStatus = {{ job.status | tojson }};
const PHASES = ['connecting','snapshot','downloading','compressing','uploading','cleanup','done'];
function setProgress(prog) {
const pct = Math.min(100, Math.max(0, prog.pct || 0));
const phase = prog.phase || '';
const detail = prog.detail || '';
document.getElementById('progressPct').textContent = pct + '%';
document.getElementById('progressDetail').textContent = detail || 'Working…';
const bar = document.getElementById('progressBar');
bar.style.width = pct + '%';
bar.className = 'progress-bar-fill';
if (pct >= 100) {
bar.classList.add('done');
} else if (phase === 'failed') {
bar.classList.add('failed');
} else {
bar.classList.add('pulse');
}
// Update phase step indicators
const phaseIdx = PHASES.indexOf(phase);
PHASES.forEach((p, i) => {
const el = document.getElementById('phase-' + p);
if (!el) return;
el.className = 'phase-step';
if (i < phaseIdx) el.classList.add('done');
else if (i === phaseIdx) el.classList.add('active');
});
}
function setStatusBadge(status) {
let html = '';
if (status === 'running') html = '<span class="badge badge-purple running-badge" style="margin-left:10px;vertical-align:middle;">⏳ Running</span>';
else if (status === 'finished') html = '<span class="badge badge-green" style="margin-left:10px;vertical-align:middle;">✓ Finished</span>';
else if (status === 'queued') html = '<span class="badge badge-yellow" style="margin-left:10px;vertical-align:middle;">⏱ Queued</span>';
else if (status.startsWith('failed')) html = '<span class="badge badge-red" style="margin-left:10px;vertical-align:middle;">✕ Failed</span>';
document.getElementById('statusBadge').innerHTML = html;
document.getElementById('statusText').textContent = status;
}
async function fetchLog() { async function fetchLog() {
try { try {
const res = await fetch('/job/' + jobId + '/log'); const res = await fetch('/job/' + jobId + '/log');
const text = await res.text(); const text = await res.text();
const pre = document.getElementById('logContent'); const pre = document.getElementById('logContent');
pre.textContent = text || '(log is empty)'; if (text.trim()) pre.textContent = text;
} catch(e) { } catch(e) {}
document.getElementById('logContent').textContent = 'Error loading log: ' + e;
}
} }
function scrollLogBottom() { function scrollLogBottom() {
@ -171,20 +276,48 @@
pre.scrollTop = pre.scrollHeight; pre.scrollTop = pre.scrollHeight;
} }
async function poll() {
try {
const res = await fetch('/api/job/' + jobId + '/status');
const data = await res.json();
const status = data.status || '';
const prog = data.progress || {};
setStatusBadge(status);
setProgress(prog);
await fetchLog();
scrollLogBottom();
const isActive = (status === 'running' || status === 'queued');
document.getElementById('spinnerWrap').style.display = isActive ? 'inline-block' : 'none';
document.getElementById('autoRefreshLabel').style.display = isActive ? '' : 'none';
if (!isActive) {
// Final state — stop polling
clearInterval(pollTimer);
// Show full-width done/fail indicator
if (status === 'finished') {
setProgress({ pct: 100, phase: 'done', detail: 'Backup completed successfully ✓' });
}
}
} catch(e) {}
}
// Initial load // Initial load
fetchLog().then(scrollLogBottom); fetchLog().then(scrollLogBottom);
// Poll while running // Apply initial progress if already running
if (status === 'running' || status === 'queued') { {% if job.status == 'running' or job.status == 'queued' %}
setInterval(() => { document.getElementById('spinnerWrap').style.display = 'inline-block';
fetchLog().then(() => { document.getElementById('autoRefreshLabel').style.display = '';
scrollLogBottom(); const pollTimer = setInterval(poll, 2000);
// reload page if done to update status badge poll();
fetch('/api/job/' + jobId + '/status') {% elif job.status == 'finished' %}
.then(r => r.json()) setProgress({ pct: 100, phase: 'done', detail: 'Backup completed successfully ✓' });
.then(d => { if (d.status !== 'running' && d.status !== 'queued') location.reload(); }); {% elif job.status.startsWith('failed') %}
}); setProgress({ pct: 0, phase: 'failed', detail: '{{ job.status }}' });
}, 3000); {% else %}
} var pollTimer;
{% endif %}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,211 @@
{% extends "base.html" %}
{% set active_page = 'nfs' %}
{% block title %}NFS Manager — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.nfs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.nfs-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
transition: all .2s;
position: relative;
overflow: hidden;
}
.nfs-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, var(--accent-2), #00a8cc);
}
.nfs-card:hover { border-color: var(--border-bright); transform: translateY(-2px); }
.nfs-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 12px; }
.nfs-device { font-size: 14px; font-weight: 600; color: var(--accent-2); word-break: break-all; }
.nfs-mountpoint { font-size: 12.5px; color: var(--text-secondary); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }
.disk-bar-wrap { margin: 14px 0 10px; }
.disk-bar-label { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--text-muted); margin-bottom: 5px; }
.disk-bar {
height: 6px; border-radius: 100px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
.disk-bar-fill {
height: 100%; border-radius: 100px;
background: linear-gradient(90deg, var(--accent-2), var(--accent));
transition: width .4s ease;
}
.disk-bar-fill.warn { background: linear-gradient(90deg, var(--warning), #e07b00); }
.disk-bar-fill.danger { background: linear-gradient(90deg, var(--danger), #c0392b); }
.disk-stats { display: flex; gap: 12px; margin-top: 8px; }
.ds { font-size: 11.5px; }
.ds-val { font-weight: 600; }
.ds-lbl { color: var(--text-muted); }
.nfs-actions { margin-top: 14px; display: flex; gap: 8px; }
.mount-form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
max-width: 640px;
}
.form-row-3 { display: grid; grid-template-columns: 2fr 2fr 1fr; gap: 12px; }
.form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.empty-state {
text-align: center; padding: 50px 20px;
color: var(--text-secondary);
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: .5; }
.linux-warn {
background: rgba(245,158,11,.08);
border: 1px solid rgba(245,158,11,.25);
border-radius: var(--radius-sm);
padding: 14px 18px;
color: var(--warning);
margin-bottom: 20px;
font-size: 13.5px;
}
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">NFS / CIFS Manager</div>
<div class="topbar-subtitle">Manage network storage mounts used as backup targets</div>
</div>
<div class="topbar-actions">
<a href="/nfs" class="btn btn-ghost btn-sm">🔄 Refresh</a>
</div>
</div>
<div class="content">
{% if not is_linux %}
<div class="linux-warn">
⚠ NFS management requires Linux. This server is running on
<strong>{{ request.environ.get('SERVER_SOFTWARE', 'non-Linux OS') }}</strong>.
Mount/unmount operations will work on the deployed Linux server.
</div>
{% endif %}
<!-- Currently mounted shares -->
<h3 style="font-size:15px; font-weight:600; margin-bottom:14px;">
📡 Mounted Shares
{% if mounts %}
<span class="badge badge-green" style="margin-left:8px; vertical-align:middle;">{{ mounts|length }} active</span>
{% endif %}
</h3>
{% if mounts %}
<div class="nfs-grid">
{% for m in mounts %}
<div class="nfs-card">
<div class="nfs-header">
<div>
<div class="nfs-device">{{ m.device }}</div>
<div class="nfs-mountpoint">→ {{ m.mountpoint }}</div>
</div>
<span class="badge badge-{{ 'purple' if m.fstype == 'nfs4' else 'gray' }}">
{{ m.fstype | upper }}
</span>
</div>
{% if m.total_gb %}
<div class="disk-bar-wrap">
<div class="disk-bar-label">
<span>{{ m.used_gb }} GB used</span>
<span>{{ m.pct_used }}%</span>
</div>
<div class="disk-bar">
<div class="disk-bar-fill {% if m.pct_used > 90 %}danger{% elif m.pct_used > 75 %}warn{% endif %}"
style="width: {{ m.pct_used }}%"></div>
</div>
</div>
<div class="disk-stats">
<div class="ds"><span class="ds-val">{{ m.total_gb }} GB</span><br><span class="ds-lbl">Total</span></div>
<div class="ds"><span class="ds-val" style="color:var(--success)">{{ m.free_gb }} GB</span><br><span class="ds-lbl">Free</span></div>
<div class="ds"><span class="ds-val" style="color:var(--warning)">{{ m.used_gb }} GB</span><br><span class="ds-lbl">Used</span></div>
</div>
{% endif %}
<div class="nfs-actions">
<a href="/jobs/create?dest={{ m.mountpoint|urlencode }}"
class="btn btn-primary btn-sm">📋 Use as Target</a>
<form method="post" action="/nfs/umount"
onsubmit="return confirm('Unmount {{ m.mountpoint }}?')">
<input type="hidden" name="mountpoint" value="{{ m.mountpoint }}" />
<button class="btn btn-danger btn-sm" type="submit">⏏ Unmount</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state" style="margin-bottom:28px;">
<div class="empty-icon">📡</div>
<p>No NFS shares currently mounted.</p>
</div>
{% endif %}
<!-- Mount new share form -->
<h3 style="font-size:15px; font-weight:600; margin-bottom:14px;"> Mount New Share</h3>
<div class="mount-form-card">
<form method="post" action="/nfs/mount" id="mountForm">
<div class="form-row-3" style="margin-bottom:14px;">
<div class="form-group" style="margin:0;">
<label class="form-label" for="server">NFS Server</label>
<input id="server" class="form-control" type="text" name="server"
placeholder="e.g. 192.168.1.100" required />
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="export">Export Path</label>
<input id="export" class="form-control" type="text" name="export"
placeholder="e.g. /mnt/backup" required />
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="nfs_version">NFS Version</label>
<select id="nfs_version" class="form-control" name="nfs_version">
<option value="4">NFSv4</option>
<option value="3">NFSv3</option>
<option value="">Auto</option>
</select>
</div>
</div>
<div class="form-row-2" style="margin-bottom:14px;">
<div class="form-group" style="margin:0;">
<label class="form-label" for="mountpoint">Mount Point (local path)</label>
<input id="mountpoint" class="form-control" type="text" name="mountpoint"
placeholder="e.g. /mnt/nfs-backup" required />
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="extra_opts">Extra Options (optional)</label>
<input id="extra_opts" class="form-control" type="text" name="extra_opts"
placeholder="e.g. ro,noatime" />
</div>
</div>
<button type="submit" class="btn btn-primary" {% if not is_linux %}disabled title="Linux only"{% endif %}>
📡 Mount Share
</button>
{% if not is_linux %}
<span class="text-muted text-small" style="margin-left:10px;">Disabled on non-Linux</span>
{% endif %}
</form>
</div>
</div>
{% endblock %}

View File

@ -112,10 +112,18 @@
<div class="topbar"> <div class="topbar">
<div> <div>
<div class="topbar-title">Virtual Machines</div> <div class="topbar-title">Virtual Machines</div>
<div class="topbar-subtitle">{{ vms|length }} VM{% if vms|length != 1 %}s{% endif %} found on <strong>{{ session.get('host') }}</strong></div> <div class="topbar-subtitle">
{{ vms|length }} VM{% if vms|length != 1 %}s{% endif %} on <strong>{{ session.get('host') }}</strong>
{% if cache_age is not none %}
&nbsp;·&nbsp;
<span class="text-muted" style="font-size:12px;">
{% if cache_age < 5 %}just refreshed{% else %}data from {{ cache_age }}s ago{% endif %}
</span>
{% endif %}
</div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<a href="/vms" class="btn btn-ghost btn-sm">🔄 Refresh</a> <a href="/vms?refresh=1" class="btn btn-ghost btn-sm">🔄 Refresh</a>
<a href="/jobs/create" class="btn btn-primary btn-sm"> Create Job</a> <a href="/jobs/create" class="btn btn-primary btn-sm"> Create Job</a>
</div> </div>
</div> </div>
@ -183,15 +191,15 @@
<div class="vm-stats"> <div class="vm-stats">
<div class="stat"> <div class="stat">
<div class="stat-label">CPUs</div> <div class="stat-label">CPUs</div>
<div class="stat-value">{{ vm.num_cpu }}</div> <div class="stat-value">{{ vm.num_cpu or '—' }}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Memory</div> <div class="stat-label">Memory</div>
<div class="stat-value">{{ (vm.memory_mb / 1024)|round(1) }} GB</div> <div class="stat-value">{% if vm.memory_mb %}{{ (vm.memory_mb / 1024)|round(1) }} GB{% else %}—{% endif %}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Disk Used</div> <div class="stat-label">Disk Used</div>
<div class="stat-value">{{ vm.committed_gb }} GB</div> <div class="stat-value">{{ vm.committed_gb or 0 }} GB</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">IP Address</div> <div class="stat-label">IP Address</div>