feat: add core backup functionality and GUI scaffolding for vSphere VMs
This commit is contained in:
parent
474192b186
commit
505bd2b0f5
Binary file not shown.
Binary file not shown.
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
211
vsphere_backup/templates/nfs.html
Normal file
211
vsphere_backup/templates/nfs.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
·
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user