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
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:

View File

@ -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/<jobid>/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)

View File

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

View File

@ -143,10 +143,16 @@
<div class="section-card">
<div class="section-card-header">📁 Destination</div>
<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">
<label class="form-label" for="dest">Local backup path</label>
<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 class="form-check">
<input type="checkbox" id="compress" name="compress" />
@ -320,5 +326,32 @@
{% if show_schedule %}
selectSchedule('daily');
{% 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>
{% 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-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 {
background: #0a0c10;
border: 1px solid var(--border);
@ -41,20 +97,8 @@
line-height: 1.6;
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; }
@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>
{% endblock %}
@ -63,15 +107,19 @@
<div>
<div class="topbar-title">
{{ job.label or 'Backup Job' }}
{% if job.status == 'running' %}
<span class="badge badge-purple running-badge" style="margin-left:10px; vertical-align:middle;">⏳ Running</span>
{% elif job.status == 'finished' %}
<span class="badge badge-green" style="margin-left:10px; vertical-align:middle;">✓ Finished</span>
{% elif job.status == 'queued' %}
<span class="badge badge-yellow" style="margin-left:10px; vertical-align:middle;">⏱ Queued</span>
{% elif job.status.startswith('failed') %}
<span class="badge badge-red" style="margin-left:10px; vertical-align:middle;">✕ Failed</span>
{% endif %}
<span id="statusBadge">
{% if job.status == 'running' %}
<span class="badge badge-purple running-badge" style="margin-left:10px; vertical-align:middle;">⏳ Running</span>
{% elif job.status == 'finished' %}
<span class="badge badge-green" style="margin-left:10px; vertical-align:middle;">✓ Finished</span>
{% elif job.status == 'queued' %}
<span class="badge badge-yellow" style="margin-left:10px; vertical-align:middle;">⏱ Queued</span>
{% elif job.status.startswith('failed') %}
<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 %}
</span>
</div>
<div class="topbar-subtitle">Job ID: <span class="mono">{{ job.id }}</span></div>
</div>
@ -89,7 +137,7 @@
</div>
<div class="detail-item">
<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 class="detail-item">
<div class="detail-item-label">Schedule</div>
@ -130,21 +178,38 @@
</div>
{% 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-toolbar">
<div class="log-title">📄 Backup Log</div>
<div class="flex-center gap-2">
{% if job.status == 'running' %}
<span class="spinner"></span>
<span class="text-small text-muted">Auto-refreshing…</span>
{% endif %}
<span id="spinnerWrap" class="spinner" style="display:none;"></span>
<span id="autoRefreshLabel" class="text-small text-muted" style="display:none;">Live</span>
<button class="btn btn-ghost btn-sm" onclick="scrollLogBottom()">⬇ Bottom</button>
</div>
</div>
<pre id="logContent">
<div class="log-empty">Loading log…</div>
</pre>
<pre id="logContent">(Loading…)</pre>
</div>
</div>
@ -152,18 +217,58 @@
{% block scripts %}
<script>
const jobId = {{ job.id | tojson }};
const status = {{ job.status | tojson }};
const jobId = {{ job.id | 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() {
try {
const res = await fetch('/job/' + jobId + '/log');
const res = await fetch('/job/' + jobId + '/log');
const text = await res.text();
const pre = document.getElementById('logContent');
pre.textContent = text || '(log is empty)';
} catch(e) {
document.getElementById('logContent').textContent = 'Error loading log: ' + e;
}
const pre = document.getElementById('logContent');
if (text.trim()) pre.textContent = text;
} catch(e) {}
}
function scrollLogBottom() {
@ -171,20 +276,48 @@
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
fetchLog().then(scrollLogBottom);
// Poll while running
if (status === 'running' || status === 'queued') {
setInterval(() => {
fetchLog().then(() => {
scrollLogBottom();
// reload page if done to update status badge
fetch('/api/job/' + jobId + '/status')
.then(r => r.json())
.then(d => { if (d.status !== 'running' && d.status !== 'queued') location.reload(); });
});
}, 3000);
}
// Apply initial progress if already running
{% if job.status == 'running' or job.status == 'queued' %}
document.getElementById('spinnerWrap').style.display = 'inline-block';
document.getElementById('autoRefreshLabel').style.display = '';
const pollTimer = setInterval(poll, 2000);
poll();
{% elif job.status == 'finished' %}
setProgress({ pct: 100, phase: 'done', detail: 'Backup completed successfully ✓' });
{% elif job.status.startsWith('failed') %}
setProgress({ pct: 0, phase: 'failed', detail: '{{ job.status }}' });
{% else %}
var pollTimer;
{% endif %}
</script>
{% 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>
<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 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>
</div>
</div>
@ -183,15 +191,15 @@
<div class="vm-stats">
<div class="stat">
<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 class="stat">
<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 class="stat">
<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 class="stat">
<div class="stat-label">IP Address</div>