feat: implement vSphere backup manager web interface with job tracking and dashboard functionality

This commit is contained in:
Rizqi 2026-06-21 03:27:28 +07:00
commit 474192b186
14 changed files with 2381 additions and 0 deletions

25
vsphere_backup/README.md Normal file
View File

@ -0,0 +1,25 @@
# vSphere Snapshot Backup Tool
Simple CLI to automate the snapshot -> copy -> compress -> delete workflow for a VM on vCenter/ESXi.
Requirements
- Python 3.8+
- See `requirements.txt` (pyvmomi, requests, paramiko, zstandard)
Basic usage
```bash
python vsphere_backup.py --host vc.example.local --user administrator@vsphere.local --vm MyVM --dest /backups/MyVM --compress
```
Optional SFTP upload
```bash
python vsphere_backup.py --host vc.example.local --user admin --vm MyVM --dest /tmp/backups --sftp-host backup.example.com --sftp-user backup --sftp-password secret
```
Notes & caveats
- The script creates a snapshot on the VM and downloads the VM's `.vmdk` and `.vmx` files from the datastore while the snapshot exists — do NOT copy `.vmdk` without snapshot.
- The script attempts to use `zstd -19` if available; otherwise it falls back to Python `zstandard`.
- SSL verification is disabled with `--no-verify-ssl` for convenience with self-signed vCenter/ESXi certs.
- Test carefully in dev before using in production. This is a minimal DIY backup tool and does not replace a full backup product.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,319 @@
import atexit
import getpass
import os
import re
import ssl
import sys
import time
import urllib.parse
from contextlib import redirect_stdout, redirect_stderr
from pathlib import Path
import requests
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
try:
import paramiko
except Exception:
paramiko = None
def get_si(host, user, pwd, no_verify_ssl=False):
context = None
if no_verify_ssl:
context = ssl._create_unverified_context()
si = SmartConnect(host=host, user=user, pwd=pwd, sslContext=context)
# Caller is responsible for disconnect via Disconnect(si)
return si
def list_vms(host, user, password, no_verify_ssl=False):
"""Connect to vCenter/ESXi and return a list of VM info dicts."""
si = None
try:
si = get_si(host, user, password, no_verify_ssl=no_verify_ssl)
content = si.RetrieveContent()
obj_view = content.viewManager.CreateContainerView(
content.rootFolder, [vim.VirtualMachine], True
)
vms = []
for vm in obj_view.view:
try:
summary = vm.summary
config = summary.config
runtime = summary.runtime
guest = summary.guest
storage = summary.storage
# Power state
power_map = {
vim.VirtualMachinePowerState.poweredOn: 'poweredOn',
vim.VirtualMachinePowerState.poweredOff: 'poweredOff',
vim.VirtualMachinePowerState.suspended: 'suspended',
}
power_state = power_map.get(runtime.powerState, str(runtime.powerState))
# Datastore names
ds_names = []
try:
for ds in vm.datastore:
ds_names.append(ds.info.name)
except Exception:
pass
vms.append({
'name': config.name,
'power_state': power_state,
'num_cpu': config.numCpu,
'memory_mb': config.memorySizeMB,
'guest_os': config.guestFullName or config.guestId or 'Unknown',
'ip_address': (guest.ipAddress or '') if guest else '',
'datastores': ds_names,
'committed_gb': round((storage.committed or 0) / (1024 ** 3), 2),
'tools_status': (guest.toolsStatus or 'unknown') if guest else 'unknown',
})
except Exception as e:
vms.append({'name': getattr(vm, 'name', '?'), 'error': str(e),
'power_state': 'unknown', 'num_cpu': 0,
'memory_mb': 0, 'guest_os': '', 'ip_address': '',
'datastores': [], 'committed_gb': 0, 'tools_status': 'unknown'})
obj_view.Destroy()
return vms
finally:
if si:
try:
Disconnect(si)
except Exception:
pass
def wait_for_task(task, action_name='job'):
while task.info.state == vim.TaskInfo.State.running:
time.sleep(1)
if task.info.state == vim.TaskInfo.State.success:
return task.info.result
else:
raise Exception(f"{action_name} did not complete successfully: {task.info.error}")
def create_snapshot(vm, snap_name, desc="backup snapshot", memory=False, quiesce=False):
print(f"Creating snapshot '{snap_name}'")
task = vm.CreateSnapshot_Task(name=snap_name, description=desc, memory=memory, quiesce=quiesce)
wait_for_task(task, 'CreateSnapshot')
print("Snapshot created")
def find_datacenter_for_datastore(content, datastore_name):
for dc in content.rootFolder.childEntity:
if isinstance(dc, vim.Datacenter):
for ds in dc.datastore:
if ds.info.name == datastore_name:
return dc
return None
def download_datastore_file(host, dc_name, datastore_name, ds_path, local_path, session_cookie, verify_ssl=True):
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)}"
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()
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):
if chunk:
f.write(chunk)
print("Download completed")
def extract_session_cookie(si):
raw = getattr(si._stub, 'cookie', '')
m = re.search(r"vmware_soap_session\s*=\s*\"?([A-Za-z0-9\-_]+)\"?", raw)
if m:
return m.group(1)
return None
def vm_disk_vmdk_paths(vm):
files = set()
for dev in vm.config.hardware.device:
if isinstance(dev, vim.vm.device.VirtualDisk):
backing = dev.backing
fn = getattr(backing, 'fileName', None)
if fn:
files.add(fn)
return list(files)
def vm_config_vmx_path(vm):
return getattr(vm.config.files, 'vmPathName', None)
def parse_datastore_path(ds_file_ref):
m = re.match(r"\[(?P<ds>[^\]]+)\]\s*(?P<path>.+)", ds_file_ref)
if not m:
raise ValueError(f"Unexpected datastore file format: {ds_file_ref}")
return m.group('ds'), m.group('path')
def find_snapshot_by_name(snapshots, name):
for snap in snapshots:
if snap.name == name:
return snap.snapshot
if snap.childSnapshotList:
found = find_snapshot_by_name(snap.childSnapshotList, name)
if found:
return found
return None
def remove_snapshot(snapshot_obj):
print("Removing snapshot")
task = snapshot_obj.RemoveSnapshot_Task(removeChildren=False)
wait_for_task(task, 'RemoveSnapshot')
print("Snapshot removed")
def maybe_compress(path):
try:
import subprocess
rc = subprocess.run(['zstd', '-19', path], check=False)
if rc.returncode == 0:
return path + '.zst'
except FileNotFoundError:
pass
try:
import zstandard as zstd
out_path = path + '.zst'
with open(path, 'rb') as ifh, open(out_path, 'wb') as ofh:
cctx = zstd.ZstdCompressor(level=19)
cctx.copy_stream(ifh, ofh)
return out_path
except Exception:
print('Compression not available; skipping')
return path
def upload_via_sftp(host, user, password, key_filename, local_path, remote_dir):
if paramiko is None:
raise RuntimeError("paramiko is required for SFTP upload")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if key_filename:
client.connect(hostname=host, username=user, key_filename=key_filename)
else:
client.connect(hostname=host, username=user, password=password)
sftp = client.open_sftp()
try:
try:
sftp.chdir(remote_dir)
except IOError:
sftp.mkdir(remote_dir)
sftp.chdir(remote_dir)
fname = os.path.basename(local_path)
print(f"Uploading {local_path} to {host}:{remote_dir}/{fname}")
sftp.put(local_path, fname)
finally:
sftp.close()
client.close()
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."""
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)
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)
def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
sftp_host, sftp_user, sftp_password, sftp_key):
si = None
try:
si = get_si(host, user, password, no_verify_ssl=no_verify_ssl)
content = si.RetrieveContent()
# find vm
obj_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True)
vm = None
for v in obj_view.view:
if v.name == vm_name:
vm = v
break
obj_view.Destroy()
if not vm:
raise Exception(f"VM named {vm_name} not found")
snap_name = f"backup-{int(time.time())}"
created_snapshot = False
try:
create_snapshot(vm, snap_name, desc="Automated backup snapshot", memory=False, quiesce=False)
created_snapshot = True
session_cookie = extract_session_cookie(si)
if not session_cookie:
raise Exception('Could not extract session cookie for downloads')
vmdk_refs = vm_disk_vmdk_paths(vm)
vmx_ref = vm_config_vmx_path(vm)
all_refs = vmdk_refs[:]
if vmx_ref:
all_refs.append(vmx_ref)
downloaded_files = []
for ref in all_refs:
ds_name, ds_path = parse_datastore_path(ref)
dc = find_datacenter_for_datastore(content, ds_name)
if not dc:
raise Exception(f"Datacenter for datastore {ds_name} not found")
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)
downloaded_files.append(local_file)
final_files = []
for f in downloaded_files:
if compress:
cf = maybe_compress(f)
final_files.append(cf)
else:
final_files.append(f)
if sftp_host:
if not sftp_user:
raise Exception('SFTP user required')
for f in final_files:
upload_via_sftp(sftp_host, sftp_user, sftp_password, sftp_key, f, os.path.basename(dest))
print('Backup completed successfully')
finally:
if created_snapshot:
snap_root = getattr(vm, 'snapshot', None)
if snap_root and snap_root.rootSnapshotList:
snap_obj = find_snapshot_by_name(snap_root.rootSnapshotList, snap_name)
if snap_obj:
try:
remove_snapshot(snap_obj)
except Exception as e:
print(f'Failed to remove snapshot: {e}', file=sys.stderr)
else:
print('Snapshot object not found in tree; may have been removed already')
finally:
if si:
try:
Disconnect(si)
except Exception:
pass

412
vsphere_backup/gui_app.py Normal file
View File

@ -0,0 +1,412 @@
"""
gui_app.py vSphere Backup Manager
Flask web UI: login VM browser create jobs schedule backups
"""
import os
import sys
import uuid
import threading
import time
import json
from datetime import datetime
from functools import wraps
from pathlib import Path
from flask import (
Flask, request, redirect, url_for, session,
flash, jsonify, abort, render_template
)
from backup_core import run_backup, list_vms
# ── APScheduler (optional graceful degradation) ──────────────────────────────
try:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
HAS_SCHEDULER = True
except ImportError:
HAS_SCHEDULER = False
print("WARNING: APScheduler not installed — recurring schedules disabled. "
"Install with: pip install APScheduler", file=sys.stderr)
# ── App setup ─────────────────────────────────────────────────────────────────
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', 'vsphere-backup-dev-key-change-me')
BASE_DIR = Path(__file__).resolve().parent
JOBS_DIR = BASE_DIR / 'jobs'
JOBS_DIR.mkdir(exist_ok=True)
# In-memory job store: {job_id: job_dict}
# job_dict keys: id, label, vm_name, status, started, dest, compress,
# no_verify_ssl, sftp_host, sftp_user, sftp_password,
# log, schedule_type, schedule_time, schedule_id
jobs: dict = {}
# APScheduler instance
scheduler = None
if HAS_SCHEDULER:
scheduler = BackgroundScheduler(daemon=True)
scheduler.start()
# ── Helpers ───────────────────────────────────────────────────────────────────
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('host'):
flash('Please log in first.', 'info')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated
def fmt_time(ts):
return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') if ts else ''
def job_to_display(jid, info):
"""Convert internal job dict to template-friendly dict."""
return {
'id': jid,
'label': info.get('label', ''),
'vm_name': info.get('vm_name', ''),
'status': info.get('status', 'unknown'),
'started_fmt': fmt_time(info.get('started')),
'dest': info.get('dest', ''),
'compress': info.get('compress', False),
'sftp_host': info.get('sftp_host', ''),
'schedule_type': info.get('schedule_type', 'now'),
'schedule_time': info.get('schedule_time', ''),
'schedule_id': info.get('schedule_id'),
}
def run_job_thread(jid):
"""Worker executed in a thread (and by APScheduler)."""
info = jobs.get(jid)
if not info:
return
info['status'] = 'running'
info['started'] = time.time()
log_path = str(JOBS_DIR / jid / 'backup.log')
try:
run_backup(
host=info['host'],
user=info['user'],
password=info['password'],
vm_name=info['vm_name'],
dest=info['dest'],
compress=info.get('compress', False),
no_verify_ssl=info.get('no_verify_ssl', False),
sftp_host=info.get('sftp_host') or None,
sftp_user=info.get('sftp_user') or None,
sftp_password=info.get('sftp_password') or None,
sftp_key=None,
log_path=log_path,
)
info['status'] = 'finished'
except Exception as e:
info['status'] = f'failed ({e})'
def create_and_start_job(
vm_name, dest, compress, no_verify_ssl,
sftp_host, sftp_user, sftp_password,
schedule_type, schedule_time, weekly_day, interval_hours,
label=''
):
"""Create a job entry and either run immediately or register schedule."""
jid = datetime.now().strftime('%Y%m%d%H%M%S') + '-' + uuid.uuid4().hex[:6]
job_dir = JOBS_DIR / jid
job_dir.mkdir(parents=True, exist_ok=True)
info = {
'id': jid,
'label': label,
'host': session['host'],
'user': session['user'],
'password': session['password'],
'vm_name': vm_name,
'dest': dest,
'compress': compress,
'no_verify_ssl': no_verify_ssl,
'sftp_host': sftp_host,
'sftp_user': sftp_user,
'sftp_password': sftp_password,
'started': time.time(),
'status': 'queued',
'schedule_type': schedule_type,
'schedule_time': schedule_time,
'schedule_id': None,
}
jobs[jid] = info
if schedule_type == 'now' or not HAS_SCHEDULER:
t = threading.Thread(target=run_job_thread, args=(jid,), daemon=True)
t.start()
else:
# Build APScheduler trigger
trigger = None
if schedule_type == 'daily':
hour, minute = (schedule_time.split(':') + ['00'])[:2]
trigger = CronTrigger(hour=int(hour), minute=int(minute))
elif schedule_type == 'weekly':
hour, minute = (schedule_time.split(':') + ['00'])[:2]
trigger = CronTrigger(
day_of_week=int(weekly_day),
hour=int(hour), minute=int(minute)
)
elif schedule_type == 'interval':
trigger = IntervalTrigger(hours=max(1, int(interval_hours or 24)))
if trigger:
# Capture jid in closure
def make_runner(j):
def _runner():
run_job_thread(j)
return _runner
sched_job = scheduler.add_job(
make_runner(jid),
trigger=trigger,
id=f'backup-{jid}',
name=f'Backup {vm_name} ({label or jid[:8]})',
misfire_grace_time=3600,
max_instances=1,
)
info['schedule_id'] = sched_job.id
info['status'] = 'scheduled'
else:
# Fallback: run now
t = threading.Thread(target=run_job_thread, args=(jid,), daemon=True)
t.start()
return jid
# ── Routes ────────────────────────────────────────────────────────────────────
@app.route('/')
def index():
if session.get('host'):
return redirect(url_for('vms'))
return redirect(url_for('login'))
# ── Login / Logout ────────────────────────────────────────────────────────────
@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', '')
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')
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')
return redirect(url_for('vms'))
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
flash('Logged out.', 'info')
return redirect(url_for('login'))
# ── VM Browser ────────────────────────────────────────────────────────────────
@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)
@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
# ── Create Job ────────────────────────────────────────────────────────────────
@app.route('/jobs/create', methods=['GET', 'POST'])
@login_required
def create_job():
if request.method == 'POST':
vm_name = request.form.get('vm_name', '').strip()
dest = request.form.get('dest', './backups').strip()
compress = 'compress' in request.form
no_verify_ssl = 'no_verify_ssl' in request.form
sftp_host = request.form.get('sftp_host', '').strip() or None
sftp_user = request.form.get('sftp_user', '').strip() or None
sftp_password = request.form.get('sftp_password', '') or None
schedule_type = request.form.get('schedule_type', 'now')
daily_time = request.form.get('daily_time', '02:00')
weekly_day = request.form.get('weekly_day', '0')
weekly_time = request.form.get('weekly_time', '02:00')
interval_hrs = request.form.get('interval_hours', '24')
label = request.form.get('job_label', '').strip()
if not vm_name:
flash('Please select a virtual machine.', 'danger')
return redirect(url_for('create_job'))
# Determine schedule_time string for display
if schedule_type == 'daily':
sched_time = daily_time
elif schedule_type == 'weekly':
sched_time = weekly_time
else:
sched_time = ''
jid = create_and_start_job(
vm_name=vm_name,
dest=dest,
compress=compress,
no_verify_ssl=no_verify_ssl,
sftp_host=sftp_host,
sftp_user=sftp_user,
sftp_password=sftp_password,
schedule_type=schedule_type,
schedule_time=sched_time,
weekly_day=weekly_day,
interval_hours=interval_hrs,
label=label,
)
flash(f'Job created successfully!', 'success')
return redirect(url_for('job_detail', jobid=jid))
# GET: load VM list for the dropdown
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')
return render_template(
'create_job.html',
vms=vm_list,
selected_vm=selected_vm,
show_schedule=show_schedule,
)
# ── Jobs Dashboard ────────────────────────────────────────────────────────────
@app.route('/jobs')
@login_required
def list_jobs():
job_list = [
job_to_display(jid, info)
for jid, info in sorted(jobs.items(), key=lambda x: x[1].get('started', 0), reverse=True)
]
scheduled_count = sum(1 for j in job_list if j['schedule_id'])
return render_template('jobs.html', jobs=job_list, scheduled_count=scheduled_count)
# ── Job Detail ────────────────────────────────────────────────────────────────
@app.route('/job/<jobid>')
@login_required
def job_detail(jobid):
info = jobs.get(jobid)
if not info:
abort(404)
return render_template('job_detail.html', job=job_to_display(jobid, info))
@app.route('/job/<jobid>/log')
@login_required
def job_log(jobid):
info = jobs.get(jobid)
if not info:
abort(404)
log_path = JOBS_DIR / jobid / 'backup.log'
if not log_path.exists():
return '(No log output yet)', 200
with open(log_path, 'rb') as f:
lines = f.read().splitlines()[-300:]
return '\n'.join(line.decode('utf-8', errors='replace') for line in lines)
@app.route('/api/job/<jobid>/status')
@login_required
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})
@app.route('/job/<jobid>/cancel-schedule', methods=['POST'])
@login_required
def cancel_schedule(jobid):
info = jobs.get(jobid)
if not info:
abort(404)
sched_id = info.get('schedule_id')
if sched_id and scheduler:
try:
scheduler.remove_job(sched_id)
except Exception:
pass
info['schedule_id'] = None
info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status']
flash('Recurring schedule cancelled.', 'success')
return redirect(url_for('job_detail', jobid=jobid))
# ── Template filter ───────────────────────────────────────────────────────────
@app.template_filter('startswith')
def startswith_filter(value, prefix):
return str(value).startswith(prefix)
# ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)

View File

@ -0,0 +1,6 @@
pyvmomi>=8.0.0
requests
paramiko
zstandard
APScheduler>=3.10
Flask>=2.3

View File

@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}vSphere Backup Manager{% endblock %}</title>
<meta name="description" content="Enterprise vSphere VM backup management — schedule, monitor and manage VM backups." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg-base: #0b0d14;
--bg-surface: #111520;
--bg-card: rgba(255,255,255,0.04);
--bg-card-hover: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.08);
--border-bright: rgba(255,255,255,0.15);
--accent: #7c6bff;
--accent-2: #00d4ff;
--accent-glow: rgba(124,107,255,0.3);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--text-primary: #f0f2ff;
--text-secondary:#94a3b8;
--text-muted: #4a5568;
--sidebar-w: 240px;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 4px 24px rgba(0,0,0,0.4);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg-base);
color: var(--text-primary);
display: flex;
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 0;
position: fixed;
left: 0; top: 0; bottom: 0;
z-index: 100;
}
.sidebar-logo {
padding: 24px 20px 20px;
border-bottom: 1px solid var(--border);
}
.sidebar-logo .logo-mark {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
font-size: 18px;
box-shadow: 0 0 20px var(--accent-glow);
}
.logo-text { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.logo-sub { font-size: 11px; color: var(--text-secondary); margin-top: 1px; }
.sidebar-nav { flex: 1; padding: 16px 12px; }
.nav-section-label {
font-size: 10px; font-weight: 600; letter-spacing: 0.1em;
color: var(--text-muted); text-transform: uppercase;
padding: 0 8px; margin: 12px 0 6px;
}
.nav-link {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px; border-radius: var(--radius-sm);
color: var(--text-secondary); text-decoration: none;
font-size: 13.5px; font-weight: 500;
transition: all .18s ease;
margin-bottom: 2px;
}
.nav-link:hover { background: var(--bg-card-hover); color: var(--text-primary); }
.nav-link.active { background: rgba(124,107,255,0.15); color: var(--accent); }
.nav-link .icon { font-size: 16px; width: 20px; text-align: center; }
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.server-badge {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 12px;
}
.server-badge .server-host { font-weight: 600; color: var(--accent-2); word-break: break-all; }
.server-badge .server-user { color: var(--text-muted); }
/* ── Main content ── */
.main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
padding: 20px 32px;
border-bottom: 1px solid var(--border);
background: rgba(11,13,20,0.8);
backdrop-filter: blur(12px);
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 50;
}
.topbar-title { font-size: 20px; font-weight: 700; }
.topbar-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.content { padding: 28px 32px; flex: 1; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 9px 18px; border-radius: var(--radius-sm);
font-size: 13.5px; font-weight: 600;
cursor: pointer; border: none; text-decoration: none;
transition: all .18s ease; line-height: 1;
white-space: nowrap;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #5b52e0);
color: #fff;
box-shadow: 0 2px 12px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 20px var(--accent-glow); }
.btn-secondary {
background: var(--bg-card);
border: 1px solid var(--border-bright);
color: var(--text-primary);
}
.btn-secondary:hover { background: var(--bg-card-hover); }
.btn-danger {
background: rgba(239,68,68,0.15);
border: 1px solid rgba(239,68,68,0.3);
color: var(--danger);
}
.btn-danger:hover { background: rgba(239,68,68,0.25); }
.btn-sm { padding: 6px 12px; font-size: 12.5px; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-secondary); }
.btn-ghost:hover { border-color: var(--border-bright); color: var(--text-primary); }
/* ── Cards ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color .2s;
}
.card:hover { border-color: var(--border-bright); }
.card-header {
padding: 18px 22px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.card-title { font-size: 15px; font-weight: 600; }
.card-body { padding: 22px; }
/* ── Badges / Status chips ── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 100px;
font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
}
.badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.badge-green { background: rgba(34,197,94,.12); color: var(--success); }
.badge-green::before { background: var(--success); box-shadow: 0 0 6px var(--success); }
.badge-red { background: rgba(239,68,68,.12); color: var(--danger); }
.badge-red::before { background: var(--danger); }
.badge-yellow { background: rgba(245,158,11,.12); color: var(--warning); }
.badge-yellow::before { background: var(--warning); }
.badge-gray { background: rgba(148,163,184,.1); color: var(--text-secondary); }
.badge-gray::before { background: var(--text-secondary); }
.badge-purple { background: rgba(124,107,255,.15); color: var(--accent); }
.badge-purple::before { background: var(--accent); box-shadow: 0 0 6px var(--accent-glow); }
/* ── Form elements ── */
.form-group { margin-bottom: 18px; }
.form-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--text-secondary); margin-bottom: 6px;
}
.form-control {
width: 100%;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 10px 14px;
font-size: 14px;
font-family: inherit;
transition: border-color .18s, box-shadow .18s;
outline: none;
}
.form-control:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.form-control::placeholder { color: var(--text-muted); }
select.form-control option { background: var(--bg-surface); color: var(--text-primary); }
.form-check { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.form-check input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
.form-check label { font-size: 13.5px; color: var(--text-secondary); cursor: pointer; }
/* ── Alert / Flash ── */
.alert {
padding: 12px 16px; border-radius: var(--radius-sm);
margin-bottom: 20px; font-size: 13.5px;
display: flex; align-items: center; gap: 10px;
}
.alert-danger { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.25); color: #fca5a5; }
.alert-success { background: rgba(34,197,94,.1); border: 1px solid rgba(34,197,94,.25); color: #86efac; }
.alert-info { background: rgba(0,212,255,.08); border: 1px solid rgba(0,212,255,.2); color: #67e8f9; }
/* ── Table ── */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; padding: 11px 16px;
font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-muted); border-bottom: 1px solid var(--border);
}
td { padding: 13px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
/* ── Utilities ── */
.text-muted { color: var(--text-secondary); }
.text-small { font-size: 12px; }
.mono { font-family: 'JetBrains Mono', monospace; }
.flex { display: flex; }
.flex-center { display: flex; align-items: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.mt-1 { margin-top: 6px; }
.mt-2 { margin-top: 12px; }
.mt-3 { margin-top: 20px; }
.mb-1 { margin-bottom: 6px; }
.mb-2 { margin-bottom: 12px; }
/* ── Spinner ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,.15);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
display: inline-block;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.2); }
</style>
{% block head %}{% endblock %}
</head>
<body>
{% if session.get('host') %}
<aside class="sidebar">
<div class="sidebar-logo">
<div class="logo-mark">
<div class="logo-icon">🛡</div>
<div>
<div class="logo-text">vSphere Backup</div>
<div class="logo-sub">Manager</div>
</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-label">Navigation</div>
<a href="/vms" class="nav-link {% if active_page == 'vms' %}active{% endif %}">
<span class="icon">🖥</span> Virtual Machines
</a>
<a href="/jobs" class="nav-link {% if active_page == 'jobs' %}active{% endif %}">
<span class="icon">📋</span> Backup Jobs
</a>
<a href="/jobs/create" class="nav-link {% if active_page == 'create_job' %}active{% endif %}">
<span class="icon"></span> Create Job
</a>
</nav>
<div class="sidebar-footer">
<div class="server-badge">
<div class="server-host">{{ session.get('host', '—') }}</div>
<div class="server-user text-small">{{ session.get('user', '') }}</div>
</div>
<a href="/logout" class="btn btn-ghost btn-sm" style="width:100%;justify-content:center;margin-top:10px;">
⬅ Logout
</a>
</div>
</aside>
{% endif %}
<main class="main" {% if not session.get('host') %}style="margin-left:0"{% endif %}>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div style="padding:16px 32px 0">
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,324 @@
{% extends "base.html" %}
{% set active_page = 'create_job' %}
{% block title %}Create Backup Job — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.wizard-wrap { max-width: 680px; }
.wizard-steps {
display: flex; align-items: center; gap: 0;
margin-bottom: 28px; counter-reset: step;
}
.step {
display: flex; align-items: center; gap: 10px;
flex: 1; position: relative;
}
.step:not(:last-child)::after {
content: '';
flex: 1; height: 1px;
background: var(--border);
margin: 0 10px;
}
.step.done:not(:last-child)::after { background: var(--accent); }
.step-num {
width: 28px; height: 28px; border-radius: 50%;
border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; flex-shrink: 0;
color: var(--text-muted);
}
.step.active .step-num { border-color: var(--accent); color: var(--accent); background: rgba(124,107,255,.12); }
.step.done .step-num { border-color: var(--accent); background: var(--accent); color: #fff; }
.step-label { font-size: 12.5px; font-weight: 500; color: var(--text-muted); }
.step.active .step-label { color: var(--text-primary); }
.section-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
overflow: hidden;
}
.section-card-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.02);
display: flex; align-items: center; gap: 10px;
font-size: 14px; font-weight: 600;
}
.section-card-body { padding: 20px; }
.schedule-options {
display: grid; grid-template-columns: repeat(2, 1fr);
gap: 10px; margin-bottom: 16px;
}
.schedule-opt {
border: 2px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 16px;
cursor: pointer;
transition: all .18s;
display: flex; align-items: flex-start; gap: 10px;
}
.schedule-opt:hover { border-color: var(--border-bright); background: var(--bg-card-hover); }
.schedule-opt.selected { border-color: var(--accent); background: rgba(124,107,255,.08); }
.schedule-opt input[type=radio] { display: none; }
.schedule-opt-icon { font-size: 20px; }
.schedule-opt-title { font-size: 13.5px; font-weight: 600; }
.schedule-opt-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.schedule-detail { display: none; }
.schedule-detail.visible { display: block; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.sftp-toggle-btn {
background: none; border: none; cursor: pointer;
color: var(--accent); font-size: 13px; font-weight: 500;
display: flex; align-items: center; gap: 6px;
padding: 0; margin-top: 4px;
text-decoration: underline; text-underline-offset: 3px;
}
.sftp-section { display: none; margin-top: 16px; }
.sftp-section.visible { display: block; }
.action-bar {
display: flex; gap: 12px; align-items: center;
padding: 20px 0 0;
}
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">Create Backup Job</div>
<div class="topbar-subtitle">Configure a new backup job for a virtual machine</div>
</div>
</div>
<div class="content">
<div class="wizard-wrap">
<!-- Progress steps -->
<div class="wizard-steps">
<div class="step done">
<div class="step-num"></div>
<div class="step-label">Connected</div>
</div>
<div class="step active">
<div class="step-num">2</div>
<div class="step-label">Configure Job</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-label">Review & Run</div>
</div>
</div>
<form method="post" action="/jobs/create" id="jobForm">
<!-- VM Selection -->
<div class="section-card">
<div class="section-card-header">🖥 Virtual Machine</div>
<div class="section-card-body">
<div class="form-group" style="margin:0;">
<label class="form-label" for="vm_name">Select VM to back up</label>
<select id="vm_name" name="vm_name" class="form-control" required>
<option value="">— Choose a VM —</option>
{% for vm in vms %}
<option value="{{ vm.name }}"
{% if vm.name == selected_vm %}selected{% endif %}>
{{ vm.name }}
{% if vm.power_state == 'poweredOn' %}🟢{% elif vm.power_state == 'poweredOff' %}🔴{% else %}🟡{% endif %}
({{ vm.guest_os[:30] if vm.guest_os else 'Unknown' }})
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Destination -->
<div class="section-card">
<div class="section-card-header">📁 Destination</div>
<div class="section-card-body">
<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 />
</div>
<div class="form-check">
<input type="checkbox" id="compress" name="compress" />
<label for="compress">Compress with zstd (smaller files, slower)</label>
</div>
<div class="form-check">
<input type="checkbox" id="no_verify_ssl" name="no_verify_ssl"
{% if session.get('no_verify_ssl') %}checked{% endif %} />
<label for="no_verify_ssl">Skip SSL certificate verification</label>
</div>
<!-- SFTP Toggle -->
<button type="button" class="sftp-toggle-btn" onclick="toggleSFTP()">
<span id="sftpToggleIcon"></span>
Upload to SFTP server (optional)
</button>
<div class="sftp-section" id="sftpSection">
<div class="form-row" style="margin-top:14px;">
<div class="form-group" style="margin:0;">
<label class="form-label" for="sftp_host">SFTP Host</label>
<input id="sftp_host" class="form-control" type="text" name="sftp_host" placeholder="sftp.example.com" />
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="sftp_user">SFTP Username</label>
<input id="sftp_user" class="form-control" type="text" name="sftp_user" placeholder="backupuser" />
</div>
</div>
<div class="form-group" style="margin-top:12px;">
<label class="form-label" for="sftp_password">SFTP Password</label>
<input id="sftp_password" class="form-control" type="password" name="sftp_password" placeholder="••••••••" />
</div>
</div>
</div>
</div>
<!-- Schedule -->
<div class="section-card">
<div class="section-card-header">🕐 Schedule</div>
<div class="section-card-body">
<div class="schedule-options">
<label class="schedule-opt {% if not show_schedule %}selected{% endif %}" id="opt-now" onclick="selectSchedule('now')">
<input type="radio" name="schedule_type" value="now" {% if not show_schedule %}checked{% endif %} />
<div>
<div class="schedule-opt-icon"></div>
<div class="schedule-opt-title">Run Now</div>
<div class="schedule-opt-desc">Start the backup immediately</div>
</div>
</label>
<label class="schedule-opt {% if show_schedule %}selected{% endif %}" id="opt-daily" onclick="selectSchedule('daily')">
<input type="radio" name="schedule_type" value="daily" {% if show_schedule %}checked{% endif %}/>
<div>
<div class="schedule-opt-icon">📅</div>
<div class="schedule-opt-title">Daily</div>
<div class="schedule-opt-desc">Repeat every day at a set time</div>
</div>
</label>
<label class="schedule-opt" id="opt-weekly" onclick="selectSchedule('weekly')">
<input type="radio" name="schedule_type" value="weekly" />
<div>
<div class="schedule-opt-icon">🗓</div>
<div class="schedule-opt-title">Weekly</div>
<div class="schedule-opt-desc">Repeat every week on a specific day</div>
</div>
</label>
<label class="schedule-opt" id="opt-interval" onclick="selectSchedule('interval')">
<input type="radio" name="schedule_type" value="interval" />
<div>
<div class="schedule-opt-icon">🔁</div>
<div class="schedule-opt-title">Interval</div>
<div class="schedule-opt-desc">Repeat every N hours</div>
</div>
</label>
</div>
<!-- Daily detail -->
<div class="schedule-detail {% if show_schedule %}visible{% endif %}" id="detail-daily">
<div class="form-row">
<div class="form-group" style="margin:0;">
<label class="form-label" for="daily_time">Time (24h)</label>
<input id="daily_time" class="form-control" type="time" name="daily_time" value="02:00" />
</div>
</div>
</div>
<!-- Weekly detail -->
<div class="schedule-detail" id="detail-weekly">
<div class="form-row">
<div class="form-group" style="margin:0;">
<label class="form-label" for="weekly_day">Day of Week</label>
<select id="weekly_day" class="form-control" name="weekly_day">
<option value="0">Monday</option>
<option value="1">Tuesday</option>
<option value="2">Wednesday</option>
<option value="3">Thursday</option>
<option value="4">Friday</option>
<option value="5">Saturday</option>
<option value="6">Sunday</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="weekly_time">Time (24h)</label>
<input id="weekly_time" class="form-control" type="time" name="weekly_time" value="02:00" />
</div>
</div>
</div>
<!-- Interval detail -->
<div class="schedule-detail" id="detail-interval">
<div class="form-row">
<div class="form-group" style="margin:0;">
<label class="form-label" for="interval_hours">Every (hours)</label>
<input id="interval_hours" class="form-control" type="number"
name="interval_hours" value="24" min="1" max="8760" />
</div>
</div>
</div>
<!-- Job label -->
<div class="form-group" style="margin-top: 18px; margin-bottom:0">
<label class="form-label" for="job_label">Job label (optional)</label>
<input id="job_label" class="form-control" type="text" name="job_label"
placeholder="e.g. Nightly web-server backup" />
</div>
</div>
</div>
<div class="action-bar">
<button type="submit" id="submitBtn" class="btn btn-primary">
<span id="submitText">🚀 Create Job</span>
<span id="submitSpinner" class="spinner" style="display:none;"></span>
</button>
<a href="/vms" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function selectSchedule(type) {
['now','daily','weekly','interval'].forEach(t => {
document.getElementById('opt-' + t).classList.remove('selected');
const d = document.getElementById('detail-' + t);
if (d) d.classList.remove('visible');
});
document.getElementById('opt-' + type).classList.add('selected');
document.getElementById('opt-' + type).querySelector('input').checked = true;
const detail = document.getElementById('detail-' + type);
if (detail) detail.classList.add('visible');
}
function toggleSFTP() {
const sec = document.getElementById('sftpSection');
const ico = document.getElementById('sftpToggleIcon');
sec.classList.toggle('visible');
ico.textContent = sec.classList.contains('visible') ? '▼' : '▶';
}
document.getElementById('jobForm').addEventListener('submit', function() {
document.getElementById('submitText').textContent = 'Starting…';
document.getElementById('submitSpinner').style.display = 'inline-block';
document.getElementById('submitBtn').disabled = true;
});
{% if show_schedule %}
selectSchedule('daily');
{% endif %}
</script>
{% endblock %}

View File

@ -0,0 +1,190 @@
{% extends "base.html" %}
{% set active_page = 'jobs' %}
{% block title %}Job {{ job.id[:8] }} — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.detail-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 14px; margin-bottom: 20px;
}
.detail-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 18px;
}
.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; }
.log-wrap {
background: #0a0c10;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
position: relative;
min-height: 200px;
}
.log-toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.log-title { font-size: 13.5px; font-weight: 600; }
pre#logContent {
font-family: 'JetBrains Mono', monospace;
font-size: 12.5px;
color: #a8c5da;
white-space: pre-wrap;
word-break: break-all;
max-height: 520px;
overflow-y: auto;
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 %}
{% block content %}
<div class="topbar">
<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 %}
</div>
<div class="topbar-subtitle">Job ID: <span class="mono">{{ job.id }}</span></div>
</div>
<div class="topbar-actions">
<a href="/jobs" class="btn btn-ghost btn-sm">← All Jobs</a>
</div>
</div>
<div class="content">
<div class="detail-grid">
<div class="detail-item">
<div class="detail-item-label">Virtual Machine</div>
<div class="detail-item-val">{{ job.vm_name }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Status</div>
<div class="detail-item-val">{{ job.status }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Schedule</div>
<div class="detail-item-val">
{% if job.schedule_type and job.schedule_type != 'now' %}
🔁 {{ job.schedule_type|capitalize }}
{% if job.schedule_time %}at {{ job.schedule_time }}{% endif %}
{% else %}
One-time (Run Now)
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Started</div>
<div class="detail-item-val mono" style="font-size:13px;">{{ job.started_fmt }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Destination</div>
<div class="detail-item-val mono" style="font-size:12px; word-break:break-all;">{{ job.dest or '—' }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Options</div>
<div class="detail-item-val" style="font-size:13px;">
{% if job.compress %}🗜 Compressed{% else %}Raw{% endif %}
{% if job.sftp_host %} · 📤 SFTP: {{ job.sftp_host }}{% endif %}
</div>
</div>
</div>
{% if job.schedule_id %}
<div class="alert alert-info" style="margin-bottom:20px;">
🔁 This job has an active recurring schedule. Future backups will run automatically.
<form method="post" action="/job/{{ job.id }}/cancel-schedule"
style="display:inline; margin-left:12px;"
onsubmit="return confirm('Cancel recurring schedule?')">
<button class="btn btn-danger btn-sm" type="submit">Cancel Schedule</button>
</form>
</div>
{% endif %}
<!-- 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 %}
<button class="btn btn-ghost btn-sm" onclick="scrollLogBottom()">⬇ Bottom</button>
</div>
</div>
<pre id="logContent">
<div class="log-empty">Loading log…</div>
</pre>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const jobId = {{ job.id | tojson }};
const status = {{ job.status | tojson }};
async function fetchLog() {
try {
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;
}
}
function scrollLogBottom() {
const pre = document.getElementById('logContent');
pre.scrollTop = pre.scrollHeight;
}
// 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);
}
</script>
{% endblock %}

View File

@ -0,0 +1,182 @@
{% extends "base.html" %}
{% set active_page = 'jobs' %}
{% block title %}Backup Jobs — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.jobs-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
}
.jobs-summary {
display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;
}
.jobs-stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 18px;
text-align: center;
min-width: 90px;
}
.jobs-stat-val { font-size: 22px; font-weight: 700; }
.jobs-stat-lbl { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.jobs-table-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.status-icon { font-size: 16px; }
.job-actions { display: flex; gap: 6px; }
.empty-state {
text-align: center; padding: 60px; color: var(--text-secondary);
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: .5; }
.running-pulse {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
.schedule-tag {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(0,212,255,.08);
border: 1px solid rgba(0,212,255,.2);
color: var(--accent-2);
font-size: 11px; padding: 2px 8px; border-radius: 100px;
}
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">Backup Jobs</div>
<div class="topbar-subtitle">All scheduled and completed backup jobs</div>
</div>
<div class="topbar-actions">
<a href="/jobs/create" class="btn btn-primary btn-sm"> Create Job</a>
</div>
</div>
<div class="content">
<!-- Summary chips -->
<div class="jobs-summary">
<div class="jobs-stat">
<div class="jobs-stat-val">{{ jobs|length }}</div>
<div class="jobs-stat-lbl">Total</div>
</div>
<div class="jobs-stat">
<div class="jobs-stat-val" style="color:var(--accent)">{{ jobs|selectattr('status','equalto','running')|list|length }}</div>
<div class="jobs-stat-lbl">Running</div>
</div>
<div class="jobs-stat">
<div class="jobs-stat-val" style="color:var(--success)">{{ jobs|selectattr('status','equalto','finished')|list|length }}</div>
<div class="jobs-stat-lbl">Finished</div>
</div>
<div class="jobs-stat">
<div class="jobs-stat-val" style="color:var(--warning)">{{ jobs|selectattr('status','equalto','queued')|list|length }}</div>
<div class="jobs-stat-lbl">Queued</div>
</div>
<div class="jobs-stat">
<div class="jobs-stat-val" style="color:var(--danger)">{% set fcnt = namespace(n=0) %}{% for j in jobs %}{% if j.status.startswith('failed') %}{% set fcnt.n = fcnt.n + 1 %}{% endif %}{% endfor %}{{ fcnt.n }}</div>
<div class="jobs-stat-lbl">Failed</div>
</div>
<div class="jobs-stat">
<div class="jobs-stat-val" style="color:var(--accent-2)">{{ scheduled_count }}</div>
<div class="jobs-stat-lbl">Scheduled</div>
</div>
</div>
{% if jobs %}
<div class="jobs-table-wrap">
<table>
<thead>
<tr>
<th>Job</th>
<th>VM</th>
<th>Status</th>
<th>Schedule</th>
<th>Started</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>
<div style="font-weight:600;font-size:13px;">
{{ job.label or ('Job #' + job.id[:8]) }}
</div>
<div class="text-small text-muted mono">{{ job.id[:12] }}…</div>
</td>
<td>
<span style="font-weight:500;">{{ job.vm_name }}</span>
</td>
<td>
{% if job.status == 'running' %}
<span class="badge badge-purple running-pulse">⏳ Running</span>
{% elif job.status == 'finished' %}
<span class="badge badge-green">✓ Finished</span>
{% elif job.status == 'queued' %}
<span class="badge badge-yellow">⏱ Queued</span>
{% elif job.status.startswith('failed') %}
<span class="badge badge-red" title="{{ job.status }}">✕ Failed</span>
{% else %}
<span class="badge badge-gray">{{ job.status }}</span>
{% endif %}
</td>
<td>
{% if job.schedule_type and job.schedule_type != 'now' %}
<span class="schedule-tag">🔁 {{ job.schedule_type|capitalize }}</span>
{% else %}
<span class="text-muted text-small">One-time</span>
{% endif %}
</td>
<td class="text-small text-muted">
{{ job.started_fmt }}
</td>
<td>
<div class="job-actions">
<a href="/job/{{ job.id }}" class="btn btn-ghost btn-sm">View</a>
{% if job.schedule_id %}
<form method="post" action="/job/{{ job.id }}/cancel-schedule"
onsubmit="return confirm('Cancel this schedule?')">
<button class="btn btn-danger btn-sm" type="submit">Cancel Schedule</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📋</div>
<p>No backup jobs yet.</p>
<a href="/jobs/create" class="btn btn-primary" style="margin-top:16px;"> Create your first job</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh jobs page every 8 seconds if any jobs are running
const hasRunning = {{ 'true' if jobs|selectattr('status','equalto','running')|list|length > 0 else 'false' }};
if (hasRunning) {
setTimeout(() => location.reload(), 8000);
}
</script>
{% endblock %}

View File

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Login — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
body { align-items: center; justify-content: center; }
.login-wrap {
width: 100%;
max-width: 440px;
padding: 24px;
animation: fadeUp .4s ease;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-header { text-align: center; margin-bottom: 32px; }
.login-icon {
width: 64px; height: 64px;
background: linear-gradient(135deg, #7c6bff, #00d4ff);
border-radius: 18px;
display: flex; align-items: center; justify-content: center;
font-size: 30px;
margin: 0 auto 16px;
box-shadow: 0 0 40px rgba(124,107,255,.4);
}
.login-header h1 { font-size: 22px; font-weight: 700; }
.login-header p { color: var(--text-secondary); margin-top: 6px; font-size: 13.5px; }
.login-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.09);
border-radius: 16px;
padding: 28px 32px;
backdrop-filter: blur(16px);
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
}
.divider {
height: 1px;
background: var(--border);
margin: 20px 0;
}
.login-footer { text-align: center; margin-top: 18px; font-size: 12px; color: var(--text-muted); }
.btn-login {
width: 100%;
justify-content: center;
padding: 12px;
font-size: 15px;
}
</style>
{% endblock %}
{% block content %}
<div class="login-wrap">
<div class="login-header">
<div class="login-icon">🛡</div>
<h1>vSphere Backup Manager</h1>
<p>Connect to your vCenter or ESXi host to get started</p>
</div>
<div class="login-card">
<form method="post" action="/login" id="loginForm">
<div class="form-group">
<label class="form-label" for="host">vCenter / ESXi Host</label>
<input id="host" class="form-control" type="text" name="host"
placeholder="e.g. 192.168.1.10 or vcenter.corp.local"
value="{{ request.form.get('host', '') }}" required autocomplete="off" />
</div>
<div class="form-group">
<label class="form-label" for="user">Username</label>
<input id="user" class="form-control" type="text" name="user"
placeholder="e.g. administrator@vsphere.local"
value="{{ request.form.get('user', '') }}" required autocomplete="username" />
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input id="password" class="form-control" type="password" name="password"
placeholder="••••••••••" required autocomplete="current-password" />
</div>
<div class="divider"></div>
<div class="form-check">
<input type="checkbox" id="no_verify_ssl" name="no_verify_ssl"
{% if request.form.get('no_verify_ssl') %}checked{% endif %} />
<label for="no_verify_ssl">Skip SSL certificate verification</label>
</div>
<button id="loginBtn" type="submit" class="btn btn-primary btn-login" style="margin-top:20px;">
<span id="loginBtnText">🔐 Connect to vCenter</span>
<span id="loginBtnSpinner" class="spinner" style="display:none;"></span>
</button>
</form>
</div>
<div class="login-footer">Credentials are stored only in your browser session</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('loginForm').addEventListener('submit', function() {
document.getElementById('loginBtnText').textContent = 'Connecting…';
document.getElementById('loginBtnSpinner').style.display = 'inline-block';
document.getElementById('loginBtn').disabled = true;
});
</script>
{% endblock %}

View File

@ -0,0 +1,245 @@
{% extends "base.html" %}
{% set active_page = 'vms' %}
{% block title %}Virtual Machines — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.vms-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
margin-top: 24px;
}
.vm-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
transition: all .2s ease;
cursor: default;
position: relative;
overflow: hidden;
}
.vm-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
opacity: 0;
transition: opacity .2s;
}
.vm-card:hover { border-color: var(--border-bright); transform: translateY(-2px); box-shadow: var(--shadow); }
.vm-card:hover::before { opacity: 1; }
.vm-card-header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 14px;
}
.vm-name {
font-size: 15px; font-weight: 600; word-break: break-word;
flex: 1; padding-right: 10px;
}
.vm-os { font-size: 11.5px; color: var(--text-muted); margin-top: 2px; }
.vm-stats {
display: grid; grid-template-columns: 1fr 1fr;
gap: 10px; margin: 14px 0;
}
.stat {
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
}
.stat-label { font-size: 10.5px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
.stat-value { font-size: 14px; font-weight: 600; margin-top: 2px; }
.vm-footer {
display: flex; gap: 8px; margin-top: 16px;
flex-wrap: wrap;
}
.vm-footer .btn { flex: 1; min-width: 110px; justify-content: center; }
.search-bar {
display: flex; align-items: center; gap: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 16px;
max-width: 340px;
}
.search-bar input {
background: none; border: none; outline: none;
color: var(--text-primary); font-size: 14px; width: 100%;
}
.search-bar input::placeholder { color: var(--text-muted); }
.filter-row {
display: flex; align-items: center; gap: 12px;
margin-bottom: 4px; flex-wrap: wrap;
}
.filter-chip {
padding: 5px 14px; border-radius: 100px;
font-size: 12.5px; font-weight: 500; cursor: pointer;
border: 1px solid var(--border); background: none;
color: var(--text-secondary); transition: all .15s;
}
.filter-chip:hover { border-color: var(--border-bright); color: var(--text-primary); }
.filter-chip.active { background: rgba(124,107,255,.15); border-color: var(--accent); color: var(--accent); }
.stat-chips {
display: flex; gap: 10px; align-items: center;
flex-wrap: wrap; margin-bottom: 20px;
}
.stat-chip {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 18px;
text-align: center;
}
.stat-chip-value { font-size: 22px; font-weight: 700; }
.stat-chip-label { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.empty-state {
text-align: center; padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state .empty-icon { font-size: 48px; margin-bottom: 14px; opacity: 0.5; }
</style>
{% endblock %}
{% block content %}
<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>
<div class="topbar-actions">
<a href="/vms" class="btn btn-ghost btn-sm">🔄 Refresh</a>
<a href="/jobs/create" class="btn btn-primary btn-sm"> Create Job</a>
</div>
</div>
<div class="content">
{% if error %}
<div class="alert alert-danger">⚠ {{ error }}</div>
{% endif %}
<!-- Summary chips -->
<div class="stat-chips">
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--text-primary)">{{ vms|length }}</div>
<div class="stat-chip-label">Total VMs</div>
</div>
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--success)">{{ vms|selectattr('power_state','equalto','poweredOn')|list|length }}</div>
<div class="stat-chip-label">Powered On</div>
</div>
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--danger)">{{ vms|selectattr('power_state','equalto','poweredOff')|list|length }}</div>
<div class="stat-chip-label">Powered Off</div>
</div>
<div class="stat-chip">
<div class="stat-chip-value" style="color:var(--warning)">{{ vms|selectattr('power_state','equalto','suspended')|list|length }}</div>
<div class="stat-chip-label">Suspended</div>
</div>
</div>
<!-- Filter / Search row -->
<div class="filter-row" style="margin-bottom:16px;">
<div class="search-bar">
<span>🔍</span>
<input type="text" id="vmSearch" placeholder="Search VMs…" oninput="filterVMs()" />
</div>
<button class="filter-chip active" id="filter-all" onclick="setFilter('all')">All</button>
<button class="filter-chip" id="filter-poweredOn" onclick="setFilter('poweredOn')">🟢 On</button>
<button class="filter-chip" id="filter-poweredOff" onclick="setFilter('poweredOff')">🔴 Off</button>
<button class="filter-chip" id="filter-suspended" onclick="setFilter('suspended')">🟡 Suspended</button>
</div>
<!-- VM Cards grid -->
<div class="vms-grid" id="vmGrid">
{% for vm in vms %}
<div class="vm-card"
data-name="{{ vm.name|lower }}"
data-power="{{ vm.power_state }}">
<div class="vm-card-header">
<div>
<div class="vm-name">{{ vm.name }}</div>
<div class="vm-os">{{ vm.guest_os or 'Unknown OS' }}</div>
</div>
{% if vm.power_state == 'poweredOn' %}
<span class="badge badge-green">On</span>
{% elif vm.power_state == 'poweredOff' %}
<span class="badge badge-red">Off</span>
{% elif vm.power_state == 'suspended' %}
<span class="badge badge-yellow">Suspended</span>
{% else %}
<span class="badge badge-gray">{{ vm.power_state }}</span>
{% endif %}
</div>
<div class="vm-stats">
<div class="stat">
<div class="stat-label">CPUs</div>
<div class="stat-value">{{ vm.num_cpu }}</div>
</div>
<div class="stat">
<div class="stat-label">Memory</div>
<div class="stat-value">{{ (vm.memory_mb / 1024)|round(1) }} GB</div>
</div>
<div class="stat">
<div class="stat-label">Disk Used</div>
<div class="stat-value">{{ vm.committed_gb }} GB</div>
</div>
<div class="stat">
<div class="stat-label">IP Address</div>
<div class="stat-value mono" style="font-size:12px;">{{ vm.ip_address or '—' }}</div>
</div>
</div>
{% if vm.datastores %}
<div class="text-small text-muted mb-2">
📦 {{ vm.datastores|join(', ') }}
</div>
{% endif %}
<div class="vm-footer">
<a href="/jobs/create?vm={{ vm.name|urlencode }}"
class="btn btn-primary btn-sm">⚡ Backup Now</a>
<a href="/jobs/create?vm={{ vm.name|urlencode }}&schedule=1"
class="btn btn-secondary btn-sm">🕐 Schedule</a>
</div>
</div>
{% else %}
<div class="empty-state" style="grid-column:1/-1;">
<div class="empty-icon">🖥</div>
<p>No virtual machines found.</p>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let activeFilter = 'all';
function setFilter(filter) {
activeFilter = filter;
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
document.getElementById('filter-' + filter).classList.add('active');
filterVMs();
}
function filterVMs() {
const q = document.getElementById('vmSearch').value.toLowerCase();
document.querySelectorAll('.vm-card').forEach(card => {
const matchFilter = activeFilter === 'all' || card.dataset.power === activeFilter;
const matchSearch = card.dataset.name.includes(q);
card.style.display = (matchFilter && matchSearch) ? '' : 'none';
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
vsphere_backup.py
Automates a simple VMware vSphere VM backup using snapshots:
- create snapshot
- download VM disk files (.vmdk) and .vmx
- optional compression (zstd) locally
- optional upload to backup server via SFTP
- delete snapshot
Requires: pyvmomi, requests, paramiko, zstandard (optional)
"""
import argparse
import atexit
import getpass
import os
import re
import ssl
import sys
import time
import urllib.parse
from pathlib import Path
import requests
from backup_core import run_backup
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
try:
import paramiko
except Exception:
paramiko = None
def parse_args():
p = argparse.ArgumentParser(description="vSphere VM snapshot + download backup tool")
p.add_argument("--host", required=True, help="vCenter/ESXi host")
p.add_argument("--user", required=True, help="Username")
p.add_argument("--password", help="Password (prompted if omitted)")
p.add_argument("--vm", required=True, help="VM name to backup")
p.add_argument("--dest", required=True, help="Local destination directory for backups")
p.add_argument("--compress", action="store_true", help="Compress downloaded files with zstd -19 if available")
p.add_argument("--no-verify-ssl", action="store_true", help="Do not verify SSL certs")
p.add_argument("--sftp-host", help="Optional: upload files to SFTP host")
p.add_argument("--sftp-user", help="SFTP username")
p.add_argument("--sftp-password", help="SFTP password (or use key via --sftp-key)")
p.add_argument("--sftp-key", help="Path to private key for SFTP auth")
return p.parse_args()
def get_si(host, user, pwd, no_verify_ssl=False):
context = None
if no_verify_ssl:
context = ssl._create_unverified_context()
si = SmartConnect(host=host, user=user, pwd=pwd, sslContext=context)
atexit.register(Disconnect, si)
return si
def find_vm_by_name(content, vm_name):
obj_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True)
for vm in obj_view.view:
if vm.name == vm_name:
obj_view.Destroy()
return vm
obj_view.Destroy()
return None
def wait_for_task(task, action_name='job'):
while task.info.state == vim.TaskInfo.State.running:
time.sleep(1)
if task.info.state == vim.TaskInfo.State.success:
return task.info.result
else:
raise Exception(f"{action_name} did not complete successfully: {task.info.error}")
def create_snapshot(vm, snap_name, desc="backup snapshot", memory=False, quiesce=False):
print(f"Creating snapshot '{snap_name}'")
task = vm.CreateSnapshot_Task(name=snap_name, description=desc, memory=memory, quiesce=quiesce)
wait_for_task(task, 'CreateSnapshot')
print("Snapshot created")
def find_datacenter_for_datastore(content, datastore_name):
for dc in content.rootFolder.childEntity:
# childEntity can include folders; ensure it's a Datacenter
if isinstance(dc, vim.Datacenter):
for ds in dc.datastore:
if ds.info.name == datastore_name:
return dc
return None
def download_datastore_file(si, host, dc_name, datastore_name, ds_path, local_path, session_cookie, verify_ssl=True):
# ds_path is like "folder/file.vmdk" without leading slash
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)}"
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()
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):
if chunk:
f.write(chunk)
print("Download completed")
def extract_session_cookie(si):
# si._stub.cookie looks like 'vmware_soap_session="xxx"; Path=/'
raw = getattr(si._stub, 'cookie', '')
m = re.search(r"vmware_soap_session\s*=\s*\"?([A-Za-z0-9\-_]+)\"?", raw)
if m:
return m.group(1)
return None
def vm_disk_vmdk_paths(vm):
files = set()
for dev in vm.config.hardware.device:
if isinstance(dev, vim.vm.device.VirtualDisk):
backing = dev.backing
fn = getattr(backing, 'fileName', None)
if fn:
files.add(fn)
return list(files)
def vm_config_vmx_path(vm):
# vm.config.files.vmPathName e.g. '[datastore1] vmfolder/vm.vmx'
return getattr(vm.config.files, 'vmPathName', None)
def parse_datastore_path(ds_file_ref):
# ds_file_ref like "[datastore1] vmfolder/vm.vmdk"
m = re.match(r"\[(?P<ds>[^\]]+)\]\s*(?P<path>.+)", ds_file_ref)
if not m:
raise ValueError(f"Unexpected datastore file format: {ds_file_ref}")
return m.group('ds'), m.group('path')
def find_snapshot_by_name(snapshots, name):
for snap in snapshots:
if snap.name == name:
return snap.snapshot
if snap.childSnapshotList:
found = find_snapshot_by_name(snap.childSnapshotList, name)
if found:
return found
return None
def remove_snapshot(snapshot_obj):
print("Removing snapshot")
task = snapshot_obj.RemoveSnapshot_Task(removeChildren=False)
wait_for_task(task, 'RemoveSnapshot')
print("Snapshot removed")
def upload_via_sftp(host, user, password, key_filename, local_path, remote_dir):
if paramiko is None:
raise RuntimeError("paramiko is required for SFTP upload")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if key_filename:
client.connect(hostname=host, username=user, key_filename=key_filename)
else:
client.connect(hostname=host, username=user, password=password)
sftp = client.open_sftp()
try:
try:
sftp.chdir(remote_dir)
except IOError:
sftp.mkdir(remote_dir)
sftp.chdir(remote_dir)
fname = os.path.basename(local_path)
print(f"Uploading {local_path} to {host}:{remote_dir}/{fname}")
sftp.put(local_path, fname)
finally:
sftp.close()
client.close()
def maybe_compress(path):
# Try system zstd first
try:
import subprocess
rc = subprocess.run(['zstd', '-19', path], check=False)
if rc.returncode == 0:
return path + '.zst'
except FileNotFoundError:
pass
# fallback to python zstandard
try:
import zstandard as zstd
out_path = path + '.zst'
with open(path, 'rb') as ifh, open(out_path, 'wb') as ofh:
cctx = zstd.ZstdCompressor(level=19)
cctx.copy_stream(ifh, ofh)
return out_path
except Exception:
print('Compression not available; skipping')
return path
def main():
args = parse_args()
password = args.password or getpass.getpass('Password: ')
dest = os.path.abspath(args.dest)
os.makedirs(dest, exist_ok=True)
# Delegate to backup_core.run_backup which handles logging when called by GUI
try:
run_backup(
args.host,
args.user,
password,
args.vm,
dest,
compress=args.compress,
no_verify_ssl=args.no_verify_ssl,
sftp_host=args.sftp_host,
sftp_user=args.sftp_user,
sftp_password=args.sftp_password,
sftp_key=args.sftp_key,
)
except Exception as e:
print(f'Backup failed: {e}', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()