feat: implement vSphere backup manager web interface with job tracking and dashboard functionality
This commit is contained in:
commit
474192b186
25
vsphere_backup/README.md
Normal file
25
vsphere_backup/README.md
Normal 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.
|
||||||
BIN
vsphere_backup/__pycache__/backup_core.cpython-310.pyc
Normal file
BIN
vsphere_backup/__pycache__/backup_core.cpython-310.pyc
Normal file
Binary file not shown.
BIN
vsphere_backup/__pycache__/gui_app.cpython-310.pyc
Normal file
BIN
vsphere_backup/__pycache__/gui_app.cpython-310.pyc
Normal file
Binary file not shown.
BIN
vsphere_backup/__pycache__/vsphere_backup.cpython-310.pyc
Normal file
BIN
vsphere_backup/__pycache__/vsphere_backup.cpython-310.pyc
Normal file
Binary file not shown.
319
vsphere_backup/backup_core.py
Normal file
319
vsphere_backup/backup_core.py
Normal 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
412
vsphere_backup/gui_app.py
Normal 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)
|
||||||
6
vsphere_backup/requirements.txt
Normal file
6
vsphere_backup/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pyvmomi>=8.0.0
|
||||||
|
requests
|
||||||
|
paramiko
|
||||||
|
zstandard
|
||||||
|
APScheduler>=3.10
|
||||||
|
Flask>=2.3
|
||||||
332
vsphere_backup/templates/base.html
Normal file
332
vsphere_backup/templates/base.html
Normal 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>
|
||||||
324
vsphere_backup/templates/create_job.html
Normal file
324
vsphere_backup/templates/create_job.html
Normal 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 %}
|
||||||
190
vsphere_backup/templates/job_detail.html
Normal file
190
vsphere_backup/templates/job_detail.html
Normal 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 %}
|
||||||
182
vsphere_backup/templates/jobs.html
Normal file
182
vsphere_backup/templates/jobs.html
Normal 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 %}
|
||||||
109
vsphere_backup/templates/login.html
Normal file
109
vsphere_backup/templates/login.html
Normal 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 %}
|
||||||
245
vsphere_backup/templates/vms.html
Normal file
245
vsphere_backup/templates/vms.html
Normal 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 %}
|
||||||
237
vsphere_backup/vsphere_backup.py
Normal file
237
vsphere_backup/vsphere_backup.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user