Sync from main server - 2026-05-13 01:06:32
This commit is contained in:
315
platform/app.py
315
platform/app.py
@@ -1,10 +1,12 @@
|
|||||||
# app.py
|
# app.py
|
||||||
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
|
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER,
|
MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER,
|
||||||
@@ -20,17 +22,21 @@ from modules.backups import (
|
|||||||
container_action, get_container_status,
|
container_action, get_container_status,
|
||||||
audit_backup, delete_backup,
|
audit_backup, delete_backup,
|
||||||
get_backup_log_entries, get_backup_script_path,
|
get_backup_log_entries, get_backup_script_path,
|
||||||
|
_ssh_main, _human_bytes, _run,
|
||||||
)
|
)
|
||||||
from modules.commands import run_command
|
from modules.commands import run_command
|
||||||
from modules.users import (
|
from modules.users import (
|
||||||
get_all_users, get_user_containers, get_all_users_containers,
|
get_all_users, get_user_containers, get_all_users_containers,
|
||||||
create_user, delete_user, get_user_disk_usage,
|
create_user, delete_user, get_user_disk_usage,
|
||||||
)
|
)
|
||||||
|
from modules.cloud_backup import (
|
||||||
|
r2_test_connection, r2_list_backups, r2_get_bucket_stats,
|
||||||
|
r2_delete_backup, r2_upload_async, get_upload_job,
|
||||||
|
r2_is_configured, R2_BUCKET_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = 'navitrends-secret-key-2025'
|
app.secret_key = 'navitrends-secret-key-2025'
|
||||||
|
|
||||||
# Increase default timeout for slow VM→main-server SSH calls
|
|
||||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
|
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
|
||||||
|
|
||||||
restore_jobs = {}
|
restore_jobs = {}
|
||||||
@@ -74,14 +80,12 @@ def _stream_backup(job_id, script_path):
|
|||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# DASHBOARD
|
# PAGES
|
||||||
# Loads instantly — all heavy data fetched async via JS after page renders
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
# On the VM: skip slow SSH calls at page load — JS fetches them async via /api/dashboard
|
|
||||||
# On the main server: fetch everything normally (local calls, no SSH delay)
|
|
||||||
backups = get_local_backups()
|
backups = get_local_backups()
|
||||||
vm_backups = get_vm_backups()
|
vm_backups = get_vm_backups()
|
||||||
|
|
||||||
@@ -91,7 +95,7 @@ def dashboard():
|
|||||||
system = get_system_info()
|
system = get_system_info()
|
||||||
users = get_all_users()
|
users = get_all_users()
|
||||||
else:
|
else:
|
||||||
containers = [] # loaded async by JS via /api/dashboard
|
containers = []
|
||||||
running_count = 0
|
running_count = 0
|
||||||
system = {}
|
system = {}
|
||||||
users = []
|
users = []
|
||||||
@@ -158,8 +162,6 @@ def restore_page():
|
|||||||
@app.route('/users')
|
@app.route('/users')
|
||||||
@login_required
|
@login_required
|
||||||
def users_page():
|
def users_page():
|
||||||
# On VM: skip slow SSH call — JS loads users async via /api/users
|
|
||||||
# On main server: fetch normally (local, fast)
|
|
||||||
users = get_all_users() if RUNNING_ON_MAIN_SERVER else []
|
users = get_all_users() if RUNNING_ON_MAIN_SERVER else []
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/users.html',
|
'pages/users.html',
|
||||||
@@ -174,8 +176,6 @@ def users_page():
|
|||||||
@app.route('/settings')
|
@app.route('/settings')
|
||||||
@login_required
|
@login_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
# On VM: skip slow SSH call — JS loads system info async via /api/system
|
|
||||||
# On main server: fetch normally (local, fast)
|
|
||||||
system = get_system_info() if RUNNING_ON_MAIN_SERVER else {}
|
system = get_system_info() if RUNNING_ON_MAIN_SERVER else {}
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/settings.html',
|
'pages/settings.html',
|
||||||
@@ -188,9 +188,26 @@ def settings_page():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/cloud')
|
||||||
|
@login_required
|
||||||
|
def cloud_page():
|
||||||
|
return render_template(
|
||||||
|
'pages/cloud.html',
|
||||||
|
local_backups=get_local_backups(),
|
||||||
|
vm_backups=get_vm_backups(),
|
||||||
|
main_server=MAIN_SERVER_IP,
|
||||||
|
r2_bucket=R2_BUCKET_NAME,
|
||||||
|
r2_configured=r2_is_configured(),
|
||||||
|
active_page='cloud',
|
||||||
|
page_title='Cloud Storage',
|
||||||
|
page_subtitle='Cloudflare R2'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# API — system + stats (always from main server)
|
# API — system + stats
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/api/system')
|
@app.route('/api/system')
|
||||||
@login_required
|
@login_required
|
||||||
def api_system():
|
def api_system():
|
||||||
@@ -214,7 +231,6 @@ def api_containers():
|
|||||||
@app.route('/api/containers/all')
|
@app.route('/api/containers/all')
|
||||||
@login_required
|
@login_required
|
||||||
def api_containers_all():
|
def api_containers_all():
|
||||||
"""Root containers + rootless-user containers, all from main server."""
|
|
||||||
root_ctrs = get_all_root_containers()
|
root_ctrs = get_all_root_containers()
|
||||||
user_ctrs = get_rootless_user_containers_remote()
|
user_ctrs = get_rootless_user_containers_remote()
|
||||||
all_ctrs = root_ctrs + user_ctrs
|
all_ctrs = root_ctrs + user_ctrs
|
||||||
@@ -225,7 +241,6 @@ def api_containers_all():
|
|||||||
@app.route('/api/nav-summary')
|
@app.route('/api/nav-summary')
|
||||||
@login_required
|
@login_required
|
||||||
def api_nav_summary():
|
def api_nav_summary():
|
||||||
"""Lightweight counts for sidebar badges (one round trip)."""
|
|
||||||
root_ctrs = get_all_root_containers()
|
root_ctrs = get_all_root_containers()
|
||||||
user_ctrs = get_rootless_user_containers_remote()
|
user_ctrs = get_rootless_user_containers_remote()
|
||||||
all_ctrs = root_ctrs + user_ctrs
|
all_ctrs = root_ctrs + user_ctrs
|
||||||
@@ -236,16 +251,9 @@ def api_nav_summary():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# API — dashboard summary (fast async load)
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
@app.route('/api/dashboard')
|
@app.route('/api/dashboard')
|
||||||
@login_required
|
@login_required
|
||||||
def api_dashboard():
|
def api_dashboard():
|
||||||
"""
|
|
||||||
Single endpoint the dashboard JS calls after page render.
|
|
||||||
Returns system info + container summary + user count in one shot.
|
|
||||||
"""
|
|
||||||
system = get_system_info()
|
system = get_system_info()
|
||||||
root_ctrs = get_all_root_containers()
|
root_ctrs = get_all_root_containers()
|
||||||
user_ctrs = get_rootless_user_containers_remote()
|
user_ctrs = get_rootless_user_containers_remote()
|
||||||
@@ -266,6 +274,7 @@ def api_dashboard():
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# API — container actions
|
# API — container actions
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/api/container/action', methods=['POST'])
|
@app.route('/api/container/action', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_container_action():
|
def api_container_action():
|
||||||
@@ -291,13 +300,13 @@ def api_container_action():
|
|||||||
@app.route('/api/container/status/<name>')
|
@app.route('/api/container/status/<name>')
|
||||||
@login_required
|
@login_required
|
||||||
def api_container_status(name):
|
def api_container_status(name):
|
||||||
status_info = get_container_status(name)
|
return jsonify(get_container_status(name))
|
||||||
return jsonify(status_info)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# API — backups
|
# API — backups
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/api/backups')
|
@app.route('/api/backups')
|
||||||
@login_required
|
@login_required
|
||||||
def api_backups():
|
def api_backups():
|
||||||
@@ -375,9 +384,130 @@ def api_backup_run_status(job_id):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/backups/details', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_backup_details():
|
||||||
|
"""
|
||||||
|
Fast metadata for the Details popup — no gzip scan, just stat + sha sidecar.
|
||||||
|
Body: { backup_file: "myapps-backup-YYYYMMDD_HHMMSS.tar.gz", source: "local"|"vm" }
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
backup_file = data.get('backup_file', '').strip()
|
||||||
|
source = data.get('source', 'local').strip()
|
||||||
|
|
||||||
|
if not re.match(r'^myapps-backup-\d{8}_\d{6}\.tar\.gz$', backup_file):
|
||||||
|
return jsonify({'error': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if source == 'local':
|
||||||
|
archive_path = f'/root/backups/{backup_file}'
|
||||||
|
sha_path = archive_path + '.sha256'
|
||||||
|
else:
|
||||||
|
archive_path = f'/backups/main-server/{backup_file}'
|
||||||
|
sha_path = archive_path + '.sha256'
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'backup_file': backup_file,
|
||||||
|
'source': source,
|
||||||
|
'path': archive_path,
|
||||||
|
'size_bytes': None,
|
||||||
|
'size_human': None,
|
||||||
|
'created_at': None,
|
||||||
|
'age_days': None,
|
||||||
|
'sha256': None,
|
||||||
|
'on_cloud': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── size + mtime ──────────────────────────────────────────────
|
||||||
|
if source == 'local':
|
||||||
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
|
# direct stat on this machine
|
||||||
|
if os.path.exists(archive_path):
|
||||||
|
st = os.stat(archive_path)
|
||||||
|
result['size_bytes'] = st.st_size
|
||||||
|
result['size_human'] = _human_bytes(st.st_size)
|
||||||
|
mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
|
||||||
|
result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days
|
||||||
|
if os.path.exists(sha_path):
|
||||||
|
try:
|
||||||
|
with open(sha_path) as f:
|
||||||
|
result['sha256'] = f.read().split()[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# SSH to main server
|
||||||
|
stat_out, _ = _ssh_main(f"stat -c '%s %Y' {archive_path} 2>/dev/null")
|
||||||
|
if stat_out:
|
||||||
|
parts = stat_out.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
size_bytes = int(parts[0])
|
||||||
|
mtime = datetime.fromtimestamp(int(parts[1]), tz=timezone.utc)
|
||||||
|
result['size_bytes'] = size_bytes
|
||||||
|
result['size_human'] = _human_bytes(size_bytes)
|
||||||
|
result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days
|
||||||
|
sha_out, _ = _ssh_main(f"cat {sha_path} 2>/dev/null | awk '{{print $1}}'")
|
||||||
|
if sha_out.strip():
|
||||||
|
result['sha256'] = sha_out.strip()
|
||||||
|
|
||||||
|
elif source == 'vm':
|
||||||
|
if not RUNNING_ON_MAIN_SERVER:
|
||||||
|
# we ARE the VM — direct stat
|
||||||
|
if os.path.exists(archive_path):
|
||||||
|
st = os.stat(archive_path)
|
||||||
|
result['size_bytes'] = st.st_size
|
||||||
|
result['size_human'] = _human_bytes(st.st_size)
|
||||||
|
mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
|
||||||
|
result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days
|
||||||
|
if os.path.exists(sha_path):
|
||||||
|
try:
|
||||||
|
with open(sha_path) as f:
|
||||||
|
result['sha256'] = f.read().split()[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# SSH to VM
|
||||||
|
ssh_prefix = (
|
||||||
|
f"ssh -i {VM_KEY} -p {VM_PORT} "
|
||||||
|
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes "
|
||||||
|
f"{VM_USER}@{VM_HOST}"
|
||||||
|
)
|
||||||
|
stat_out, _ = _run(
|
||||||
|
f"{ssh_prefix} \"stat -c '%s %Y' {archive_path} 2>/dev/null\"",
|
||||||
|
timeout=20
|
||||||
|
)
|
||||||
|
if stat_out:
|
||||||
|
parts = stat_out.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
size_bytes = int(parts[0])
|
||||||
|
mtime = datetime.fromtimestamp(int(parts[1]), tz=timezone.utc)
|
||||||
|
result['size_bytes'] = size_bytes
|
||||||
|
result['size_human'] = _human_bytes(size_bytes)
|
||||||
|
result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days
|
||||||
|
sha_out, _ = _run(
|
||||||
|
f"{ssh_prefix} \"cat {sha_path} 2>/dev/null | awk '{{print $1}}'\"",
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if sha_out.strip():
|
||||||
|
result['sha256'] = sha_out.strip()
|
||||||
|
|
||||||
|
# ── R2 presence check (best-effort) ──────────────────────────
|
||||||
|
try:
|
||||||
|
r2_list = r2_list_backups()
|
||||||
|
r2_names = {b.get('name') for b in r2_list}
|
||||||
|
result['on_cloud'] = backup_file in r2_names
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# API — users
|
# API — users
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/api/users')
|
@app.route('/api/users')
|
||||||
@login_required
|
@login_required
|
||||||
def api_users():
|
def api_users():
|
||||||
@@ -431,9 +561,113 @@ def api_delete_user():
|
|||||||
return jsonify({'success': success, 'message': message})
|
return jsonify({'success': success, 'message': message})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# API — Cloudflare R2 cloud storage
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route('/api/cloud/r2/test')
|
||||||
|
@login_required
|
||||||
|
def api_r2_test():
|
||||||
|
return jsonify(r2_test_connection())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/cloud/r2/stats')
|
||||||
|
@login_required
|
||||||
|
def api_r2_stats():
|
||||||
|
return jsonify(r2_get_bucket_stats())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/cloud/r2/backups')
|
||||||
|
@login_required
|
||||||
|
def api_r2_list():
|
||||||
|
return jsonify({'backups': r2_list_backups()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/cloud/r2/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_r2_delete():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
key = data.get('key', '').strip()
|
||||||
|
if not key:
|
||||||
|
return jsonify({'success': False, 'message': 'key required'}), 400
|
||||||
|
success, message = r2_delete_backup(key)
|
||||||
|
return jsonify({'success': success, 'message': message})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/cloud/r2/audit', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_r2_audit():
|
||||||
|
from modules.cloud_backup import r2_audit_backup
|
||||||
|
data = request.get_json() or {}
|
||||||
|
key = data.get('key', '').strip()
|
||||||
|
if not key:
|
||||||
|
return jsonify({'error': 'key required'}), 400
|
||||||
|
return jsonify(r2_audit_backup(key))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/cloud/r2/upload', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_r2_upload():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
backup_file = data.get('backup_file', '').strip()
|
||||||
|
source = data.get('source', 'local').strip()
|
||||||
|
|
||||||
|
if not backup_file:
|
||||||
|
return jsonify({'success': False, 'message': 'backup_file required'}), 400
|
||||||
|
|
||||||
|
if source == 'local':
|
||||||
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
|
local_path = f"/root/backups/{backup_file}"
|
||||||
|
else:
|
||||||
|
local_path = f"/tmp/{backup_file}"
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
pull_cmd = (
|
||||||
|
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} "
|
||||||
|
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
||||||
|
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} "
|
||||||
|
f"{local_path}"
|
||||||
|
)
|
||||||
|
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return jsonify({'success': False, 'message': f'Failed to pull from main server: {res.stderr}'}), 500
|
||||||
|
else:
|
||||||
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
|
local_path = f"/tmp/{backup_file}"
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
pull_cmd = (
|
||||||
|
f"scp -i {VM_KEY} -P {VM_PORT} "
|
||||||
|
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
||||||
|
f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} "
|
||||||
|
f"{local_path}"
|
||||||
|
)
|
||||||
|
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return jsonify({'success': False, 'message': f'Failed to pull from VM: {res.stderr}'}), 500
|
||||||
|
else:
|
||||||
|
local_path = f"/backups/main-server/{backup_file}"
|
||||||
|
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
return jsonify({'success': False, 'message': f'File not found: {local_path}'}), 400
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
t = threading.Thread(target=r2_upload_async, args=(local_path, job_id), daemon=True)
|
||||||
|
t.start()
|
||||||
|
return jsonify({'success': True, 'job_id': job_id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/cloud/r2/upload/status/<job_id>')
|
||||||
|
@login_required
|
||||||
|
def api_r2_upload_status(job_id):
|
||||||
|
job = get_upload_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({'error': 'Job not found'}), 404
|
||||||
|
return jsonify(job)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# RESTORE
|
# RESTORE
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/restore/start', methods=['POST'])
|
@app.route('/restore/start', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def restore_start():
|
def restore_start():
|
||||||
@@ -443,6 +677,7 @@ def restore_start():
|
|||||||
|
|
||||||
backup_source = data.get('backup_source', 'local')
|
backup_source = data.get('backup_source', 'local')
|
||||||
backup_file = data.get('backup_file', '').strip()
|
backup_file = data.get('backup_file', '').strip()
|
||||||
|
cloud_key = data.get('cloud_key', '').strip()
|
||||||
target = data.get('target', 'local')
|
target = data.get('target', 'local')
|
||||||
remote_ip = data.get('remote_ip', '').strip()
|
remote_ip = data.get('remote_ip', '').strip()
|
||||||
remote_port = str(data.get('remote_port', '22')).strip() or '22'
|
remote_port = str(data.get('remote_port', '22')).strip() or '22'
|
||||||
@@ -454,7 +689,21 @@ def restore_start():
|
|||||||
if not backup_file:
|
if not backup_file:
|
||||||
return jsonify({'error': 'No backup file specified'}), 400
|
return jsonify({'error': 'No backup file specified'}), 400
|
||||||
|
|
||||||
if backup_source == 'local':
|
# ── Resolve backup path by source ────────────────────────────
|
||||||
|
if backup_source == 'cloud':
|
||||||
|
backup_path = f"/tmp/{backup_file}"
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
from modules.cloud_backup import _get_r2_client, _get_r2_config
|
||||||
|
try:
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
key = cloud_key or f"backups/{backup_file}"
|
||||||
|
client = _get_r2_client()
|
||||||
|
client.download_file(bucket, key, backup_path)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'Failed to download from R2: {e}'}), 500
|
||||||
|
|
||||||
|
elif backup_source == 'local':
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
backup_path = f"/root/backups/{backup_file}"
|
backup_path = f"/root/backups/{backup_file}"
|
||||||
if not os.path.exists(backup_path):
|
if not os.path.exists(backup_path):
|
||||||
@@ -471,7 +720,7 @@ def restore_start():
|
|||||||
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500
|
return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500
|
||||||
else:
|
else: # vm
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
backup_path = f"/tmp/{backup_file}"
|
backup_path = f"/tmp/{backup_file}"
|
||||||
if not os.path.exists(backup_path):
|
if not os.path.exists(backup_path):
|
||||||
@@ -500,9 +749,9 @@ def restore_start():
|
|||||||
session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}"
|
session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}"
|
||||||
cmd = (
|
cmd = (
|
||||||
f"set -e && "
|
f"set -e && "
|
||||||
f"echo '🖥️ Restoring on this server ({hostname})...' && "
|
f"echo 'Restoring on this server ({hostname})...' && "
|
||||||
f"mkdir -p {session_dir} && "
|
f"mkdir -p {session_dir} && "
|
||||||
f"echo '📂 Extracting backup...' && "
|
f"echo 'Extracting backup...' && "
|
||||||
f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && "
|
f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && "
|
||||||
f"cp {restore_script_local} {session_dir}/restore-myapps.sh && "
|
f"cp {restore_script_local} {session_dir}/restore-myapps.sh && "
|
||||||
f"chmod +x {session_dir}/restore-myapps.sh && "
|
f"chmod +x {session_dir}/restore-myapps.sh && "
|
||||||
@@ -527,14 +776,14 @@ def restore_start():
|
|||||||
|
|
||||||
remote_dest = f"/backups/restore-session-{uuid.uuid4().hex[:8]}"
|
remote_dest = f"/backups/restore-session-{uuid.uuid4().hex[:8]}"
|
||||||
cmd = (
|
cmd = (
|
||||||
f"echo '🔗 Connecting to {remote_user}@{remote_ip}:{remote_port}...' && "
|
f"echo 'Connecting to {remote_user}@{remote_ip}:{remote_port}...' && "
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} 'mkdir -p {remote_dest}' && "
|
f"{ssh_prefix} {remote_user}@{remote_ip} 'mkdir -p {remote_dest}' && "
|
||||||
f"echo '✅ Connected.' && "
|
f"echo 'Connected.' && "
|
||||||
f"echo '📤 Copying backup archive...' && "
|
f"echo 'Copying backup archive...' && "
|
||||||
f"{scp_prefix} {backup_path} {remote_user}@{remote_ip}:{remote_dest}/{backup_file} && "
|
f"{scp_prefix} {backup_path} {remote_user}@{remote_ip}:{remote_dest}/{backup_file} && "
|
||||||
f"echo '📤 Copying restore script...' && "
|
f"echo 'Copying restore script...' && "
|
||||||
f"{scp_prefix} {restore_script_local} {remote_user}@{remote_ip}:{remote_dest}/restore-myapps.sh && "
|
f"{scp_prefix} {restore_script_local} {remote_user}@{remote_ip}:{remote_dest}/restore-myapps.sh && "
|
||||||
f"echo '🚀 Running restore on {remote_ip}:{remote_port}...' && "
|
f"echo 'Running restore on {remote_ip}:{remote_port}...' && "
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} "
|
f"{ssh_prefix} {remote_user}@{remote_ip} "
|
||||||
f"'set -e && cd {remote_dest} && "
|
f"'set -e && cd {remote_dest} && "
|
||||||
f"tar -xzf {backup_file} --strip-components=1 && "
|
f"tar -xzf {backup_file} --strip-components=1 && "
|
||||||
@@ -566,6 +815,7 @@ def restore_status_poll(job_id):
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# SERVER STATUS
|
# SERVER STATUS
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/server/status')
|
@app.route('/server/status')
|
||||||
@login_required
|
@login_required
|
||||||
def server_status():
|
def server_status():
|
||||||
@@ -578,6 +828,7 @@ def server_status():
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# AUTH
|
# AUTH
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
error = ''
|
error = ''
|
||||||
|
|||||||
336
platform/modules/cloud_backup.py
Normal file
336
platform/modules/cloud_backup.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""
|
||||||
|
modules/cloud_backup.py
|
||||||
|
Cloudflare R2 cloud backup integration via boto3 (S3-compatible API)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError, NoCredentialsError
|
||||||
|
BOTO3_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
BOTO3_AVAILABLE = False
|
||||||
|
|
||||||
|
# Upload progress tracking (in-memory)
|
||||||
|
_upload_jobs: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_r2_config() -> dict:
|
||||||
|
"""Read R2 config fresh from env vars every time (not cached at import)."""
|
||||||
|
return {
|
||||||
|
"account_id": os.environ.get("R2_ACCOUNT_ID", "35e00c230cc8066252a2d9890b69aea2"),
|
||||||
|
"access_key_id": os.environ.get("R2_ACCESS_KEY_ID", ""),
|
||||||
|
"secret_access_key": os.environ.get("R2_SECRET_ACCESS_KEY", ""),
|
||||||
|
"bucket_name": os.environ.get("R2_BUCKET_NAME", "navitrends-backups"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level name used in app.py template rendering
|
||||||
|
R2_BUCKET_NAME = os.environ.get("R2_BUCKET_NAME", "navitrends-backups")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_r2_client():
|
||||||
|
"""Return a boto3 S3 client pointed at Cloudflare R2."""
|
||||||
|
if not BOTO3_AVAILABLE:
|
||||||
|
raise RuntimeError("boto3 is not installed. Run: pip install boto3")
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
if not cfg["access_key_id"] or not cfg["secret_access_key"]:
|
||||||
|
raise RuntimeError(
|
||||||
|
"R2 credentials not configured. "
|
||||||
|
"Set R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY environment variables."
|
||||||
|
)
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=f"https://{cfg['account_id']}.r2.cloudflarestorage.com",
|
||||||
|
aws_access_key_id=cfg["access_key_id"],
|
||||||
|
aws_secret_access_key=cfg["secret_access_key"],
|
||||||
|
region_name="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def r2_is_configured() -> bool:
|
||||||
|
"""Return True only if credentials are set and boto3 is available."""
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
return BOTO3_AVAILABLE and bool(cfg["access_key_id"]) and bool(cfg["secret_access_key"])
|
||||||
|
|
||||||
|
|
||||||
|
def r2_test_connection() -> dict:
|
||||||
|
if not BOTO3_AVAILABLE:
|
||||||
|
return {"success": False, "message": "boto3 not installed (pip install boto3)", "bucket_exists": False}
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
if not cfg["access_key_id"] or not cfg["secret_access_key"]:
|
||||||
|
return {"success": False, "message": "R2 credentials not set", "bucket_exists": False}
|
||||||
|
try:
|
||||||
|
client = _get_r2_client()
|
||||||
|
resp = client.list_buckets()
|
||||||
|
buckets = [b["Name"] for b in resp.get("Buckets", [])]
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
exists = bucket in buckets
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Connected to R2. Bucket '{bucket}': {'exists' if exists else 'not created yet'}",
|
||||||
|
"bucket_exists": exists,
|
||||||
|
"buckets": buckets,
|
||||||
|
}
|
||||||
|
except NoCredentialsError:
|
||||||
|
return {"success": False, "message": "Invalid R2 credentials", "bucket_exists": False}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "message": str(e), "bucket_exists": False}
|
||||||
|
|
||||||
|
|
||||||
|
def r2_ensure_bucket() -> tuple[bool, str]:
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
try:
|
||||||
|
client = _get_r2_client()
|
||||||
|
try:
|
||||||
|
client.head_bucket(Bucket=bucket)
|
||||||
|
return True, f"Bucket '{bucket}' already exists"
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] in ("404", "NoSuchBucket"):
|
||||||
|
client.create_bucket(Bucket=bucket)
|
||||||
|
return True, f"Bucket '{bucket}' created successfully"
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Failed to ensure bucket: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def r2_list_backups() -> list[dict]:
|
||||||
|
if not r2_is_configured():
|
||||||
|
return []
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
try:
|
||||||
|
client = _get_r2_client()
|
||||||
|
resp = client.list_objects_v2(Bucket=bucket, Prefix="backups/")
|
||||||
|
objects = []
|
||||||
|
for obj in resp.get("Contents", []):
|
||||||
|
# Skip .sha256 checksum files — those are audit files, not backups
|
||||||
|
if obj["Key"].endswith(".sha256"):
|
||||||
|
continue
|
||||||
|
size = obj["Size"]
|
||||||
|
objects.append({
|
||||||
|
"key": obj["Key"],
|
||||||
|
"name": obj["Key"].replace("backups/", ""),
|
||||||
|
"size": size,
|
||||||
|
"size_human": _human_size(size),
|
||||||
|
"last_modified": obj["LastModified"].isoformat(),
|
||||||
|
"last_modified_str": obj["LastModified"].strftime("%Y-%m-%d %H:%M UTC"),
|
||||||
|
})
|
||||||
|
return sorted(objects, key=lambda x: x["last_modified"], reverse=True)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def r2_delete_backup(key: str) -> tuple[bool, str]:
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
try:
|
||||||
|
client = _get_r2_client()
|
||||||
|
client.delete_object(Bucket=bucket, Key=key)
|
||||||
|
return True, f"Deleted {key} from R2"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def r2_get_bucket_stats() -> dict:
|
||||||
|
if not r2_is_configured():
|
||||||
|
return {"count": 0, "total_size": 0, "total_size_human": "0 B", "configured": False}
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
try:
|
||||||
|
client = _get_r2_client()
|
||||||
|
resp = client.list_objects_v2(Bucket=bucket, Prefix="backups/")
|
||||||
|
# Count and size only real archives, not .sha256 checksum files
|
||||||
|
objects = [o for o in resp.get("Contents", []) if not o["Key"].endswith(".sha256")]
|
||||||
|
total = sum(o["Size"] for o in objects)
|
||||||
|
return {
|
||||||
|
"count": len(objects),
|
||||||
|
"total_size": total,
|
||||||
|
"total_size_human": _human_size(total),
|
||||||
|
"configured": True,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"count": 0, "total_size": 0, "total_size_human": "0 B", "configured": True}
|
||||||
|
|
||||||
|
|
||||||
|
class _ProgressCallback:
|
||||||
|
def __init__(self, job_id: str, file_size: int):
|
||||||
|
self._job_id = job_id
|
||||||
|
self._size = file_size
|
||||||
|
self._uploaded = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def __call__(self, bytes_amount: int):
|
||||||
|
with self._lock:
|
||||||
|
self._uploaded += bytes_amount
|
||||||
|
pct = int(self._uploaded * 100 / self._size) if self._size else 0
|
||||||
|
if self._job_id in _upload_jobs:
|
||||||
|
_upload_jobs[self._job_id]["uploaded"] = self._uploaded
|
||||||
|
_upload_jobs[self._job_id]["progress"] = pct
|
||||||
|
_upload_jobs[self._job_id]["log"].append(
|
||||||
|
f" {_human_size(self._uploaded)} / {_human_size(self._size)} ({pct}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def r2_upload_async(local_path: str, job_id: str) -> None:
|
||||||
|
_upload_jobs[job_id] = {
|
||||||
|
"status": "running",
|
||||||
|
"log": [f"Starting upload: {os.path.basename(local_path)}"],
|
||||||
|
"progress": 0,
|
||||||
|
"uploaded": 0,
|
||||||
|
"started": time.time(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if not r2_is_configured():
|
||||||
|
raise RuntimeError("R2 credentials not configured")
|
||||||
|
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
|
||||||
|
ok, msg = r2_ensure_bucket()
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
_upload_jobs[job_id]["log"].append(f"OK: {msg}")
|
||||||
|
|
||||||
|
file_size = os.path.getsize(local_path)
|
||||||
|
object_key = f"backups/{os.path.basename(local_path)}"
|
||||||
|
client = _get_r2_client()
|
||||||
|
callback = _ProgressCallback(job_id, file_size)
|
||||||
|
|
||||||
|
_upload_jobs[job_id]["log"].append(
|
||||||
|
f"Uploading {_human_size(file_size)} to r2://{bucket}/{object_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client.upload_file(
|
||||||
|
local_path,
|
||||||
|
bucket,
|
||||||
|
object_key,
|
||||||
|
Callback=callback,
|
||||||
|
ExtraArgs={"Metadata": {
|
||||||
|
"uploaded-by": "navitrends-platform",
|
||||||
|
"uploaded-at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"original-file": os.path.basename(local_path),
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
|
||||||
|
sha_path = local_path + ".sha256"
|
||||||
|
if os.path.exists(sha_path):
|
||||||
|
client.upload_file(sha_path, bucket, object_key + ".sha256")
|
||||||
|
_upload_jobs[job_id]["log"].append("SHA256 checksum uploaded")
|
||||||
|
|
||||||
|
elapsed = round(time.time() - _upload_jobs[job_id]["started"], 1)
|
||||||
|
_upload_jobs[job_id]["log"].append(
|
||||||
|
f"Upload complete in {elapsed}s — r2://{bucket}/{object_key}"
|
||||||
|
)
|
||||||
|
_upload_jobs[job_id]["status"] = "done"
|
||||||
|
_upload_jobs[job_id]["progress"] = 100
|
||||||
|
_upload_jobs[job_id]["object_key"] = object_key
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_upload_jobs[job_id]["log"].append(f"Upload failed: {e}")
|
||||||
|
_upload_jobs[job_id]["status"] = "error"
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_job(job_id: str) -> dict | None:
|
||||||
|
job = _upload_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"status": job["status"],
|
||||||
|
"log": job["log"],
|
||||||
|
"progress": job["progress"],
|
||||||
|
"elapsed": round(time.time() - job.get("started", time.time())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _human_size(n: int) -> str:
|
||||||
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if n < 1024:
|
||||||
|
return f"{n:.1f} {unit}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f} TB"
|
||||||
|
|
||||||
|
def r2_audit_backup(key: str) -> dict:
|
||||||
|
"""Audit a backup in R2 by verifying its SHA256 checksum and metadata."""
|
||||||
|
cfg = _get_r2_config()
|
||||||
|
bucket = cfg["bucket_name"]
|
||||||
|
report = {
|
||||||
|
"backup": key,
|
||||||
|
"status": "unknown",
|
||||||
|
"checks": [],
|
||||||
|
"warnings": [],
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _get_r2_client()
|
||||||
|
|
||||||
|
# Check 1: Object exists
|
||||||
|
try:
|
||||||
|
head = client.head_object(Bucket=bucket, Key=key)
|
||||||
|
size = head["ContentLength"]
|
||||||
|
last_mod = head["LastModified"].strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
report["checks"].append(f"✅ Object exists in R2 ({_human_size(size)}, modified {last_mod})")
|
||||||
|
report["size"] = size
|
||||||
|
report["size_human"] = _human_size(size)
|
||||||
|
report["last_modified"] = last_mod
|
||||||
|
except ClientError as e:
|
||||||
|
report["errors"].append(f"❌ Object not found in R2: {e}")
|
||||||
|
report["status"] = "error"
|
||||||
|
return report
|
||||||
|
|
||||||
|
# Check 2: SHA256 companion file
|
||||||
|
sha_key = key + ".sha256"
|
||||||
|
try:
|
||||||
|
sha_obj = client.get_object(Bucket=bucket, Key=sha_key)
|
||||||
|
sha_content = sha_obj["Body"].read().decode("utf-8").strip()
|
||||||
|
stored_hash = sha_content.split()[0] if sha_content else ""
|
||||||
|
report["checks"].append("✅ SHA256 checksum file found")
|
||||||
|
report["stored_hash"] = (stored_hash[:16] + "…") if stored_hash else "N/A"
|
||||||
|
except ClientError:
|
||||||
|
report["warnings"].append("⚠️ No SHA256 checksum file found — integrity cannot be verified")
|
||||||
|
report["stored_hash"] = None
|
||||||
|
|
||||||
|
# Check 3: Upload metadata
|
||||||
|
metadata = head.get("Metadata", {})
|
||||||
|
uploaded_by = metadata.get("uploaded-by", "")
|
||||||
|
uploaded_at = metadata.get("uploaded-at", "")
|
||||||
|
if uploaded_by:
|
||||||
|
report["checks"].append(f"✅ Uploaded by: {uploaded_by}")
|
||||||
|
if uploaded_at:
|
||||||
|
report["checks"].append(f"✅ Upload timestamp: {uploaded_at[:19]}")
|
||||||
|
if not uploaded_by and not uploaded_at:
|
||||||
|
report["warnings"].append("⚠️ No upload metadata found (may have been uploaded outside the platform)")
|
||||||
|
|
||||||
|
# Check 4: File size sanity
|
||||||
|
if size < 10 * 1024 * 1024:
|
||||||
|
report["warnings"].append(f"⚠️ File is very small ({_human_size(size)}) — may be incomplete")
|
||||||
|
else:
|
||||||
|
report["checks"].append(f"✅ File size looks healthy ({_human_size(size)})")
|
||||||
|
|
||||||
|
# Check 5: Filename format
|
||||||
|
import re
|
||||||
|
filename = key.replace("backups/", "")
|
||||||
|
if re.match(r"myapps-backup-\d{8}_\d{6}\.tar\.gz", filename):
|
||||||
|
report["checks"].append("✅ Filename format is valid")
|
||||||
|
else:
|
||||||
|
report["warnings"].append(f"⚠️ Unexpected filename format: {filename}")
|
||||||
|
|
||||||
|
# Final status
|
||||||
|
if report["errors"]:
|
||||||
|
report["status"] = "error"
|
||||||
|
elif report["warnings"]:
|
||||||
|
report["status"] = "warning"
|
||||||
|
else:
|
||||||
|
report["status"] = "healthy"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
report["errors"].append(f"❌ Audit failed: {e}")
|
||||||
|
report["status"] = "error"
|
||||||
|
|
||||||
|
return report
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
<a class="nav-item {% if active_page == 'backups' %}active{% endif %}" href="{{ url_for('backups_page') }}">
|
<a class="nav-item {% if active_page == 'backups' %}active{% endif %}" href="{{ url_for('backups_page') }}">
|
||||||
<i class="fas fa-database"></i><span>Backups</span>
|
<i class="fas fa-database"></i><span>Backups</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-item {% if active_page == 'cloud' %}active{% endif %}" href="{{ url_for('cloud_page') }}">
|
||||||
|
<i class="fas fa-cloud"></i><span>Cloud Storage</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-label" style="margin-top:20px;">ADMIN</div>
|
<div class="nav-section-label" style="margin-top:20px;">ADMIN</div>
|
||||||
<a class="nav-item {% if active_page == 'users' %}active{% endif %}" href="{{ url_for('users_page') }}">
|
<a class="nav-item {% if active_page == 'users' %}active{% endif %}" href="{{ url_for('users_page') }}">
|
||||||
|
|||||||
@@ -1,5 +1,276 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{# ── Extra styles scoped to this page ── #}
|
||||||
|
<style>
|
||||||
|
/* ── BACKUP ITEMS ──────────────────────────────────────────────── */
|
||||||
|
.backup-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.backup-item:hover { border-color: var(--border2); background: var(--bg4); }
|
||||||
|
|
||||||
|
.backup-name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-actions { display: flex; gap: 5px; align-items: center; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── ENHANCED MODAL ─────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeInOverlay 0.18s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadeInOverlay {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 18px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 620px;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 40px 100px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
|
||||||
|
animation: slideUpModal 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .modal-box {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #dde1ed;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.14);
|
||||||
|
}
|
||||||
|
@keyframes slideUpModal {
|
||||||
|
from { opacity: 0; transform: translateY(18px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 18px 18px 0 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .modal-header { background: #fff; border-color: #dde1ed; }
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 14px;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.modal-close:hover { background: rgba(239,68,68,0.12); color: #ef4444; border-color: rgba(239,68,68,0.25); }
|
||||||
|
|
||||||
|
.modal-body { padding: 20px 24px 24px; }
|
||||||
|
|
||||||
|
/* ── AUDIT STATUS BANNER ─────────────────────────────────────── */
|
||||||
|
.audit-status-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.audit-status-banner.healthy { background: rgba(34,197,94,0.08); border-color: rgba(34,197,94,0.2); }
|
||||||
|
.audit-status-banner.warning { background: rgba(245,158,11,0.08); border-color: rgba(245,158,11,0.2); }
|
||||||
|
.audit-status-banner.critical { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.2); }
|
||||||
|
|
||||||
|
.audit-status-icon {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.audit-status-banner.healthy .audit-status-icon { background: rgba(34,197,94,0.15); color: var(--green); }
|
||||||
|
.audit-status-banner.warning .audit-status-icon { background: rgba(245,158,11,0.15); color: var(--yellow); }
|
||||||
|
.audit-status-banner.critical .audit-status-icon { background: rgba(239,68,68,0.15); color: var(--red); }
|
||||||
|
|
||||||
|
.audit-status-info { flex: 1; min-width: 0; }
|
||||||
|
.audit-status-label {
|
||||||
|
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.audit-status-banner.healthy .audit-status-label { color: var(--green); }
|
||||||
|
.audit-status-banner.warning .audit-status-label { color: var(--yellow); }
|
||||||
|
.audit-status-banner.critical .audit-status-label { color: var(--red); }
|
||||||
|
|
||||||
|
.audit-status-file {
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--text3); margin-top: 2px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.audit-status-size {
|
||||||
|
font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SCORE BAR ─────────────────────────────────────────────── */
|
||||||
|
.audit-score-row {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.audit-score-bar-wrap {
|
||||||
|
flex: 1; height: 6px; background: var(--border2); border-radius: 10px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.audit-score-bar-fill {
|
||||||
|
height: 100%; border-radius: 10px;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--cyan));
|
||||||
|
transition: width 0.8s cubic-bezier(0.34,1.2,0.64,1);
|
||||||
|
}
|
||||||
|
.audit-score-bar-fill.warn { background: linear-gradient(90deg, var(--yellow), #f97316); }
|
||||||
|
.audit-score-bar-fill.crit { background: linear-gradient(90deg, var(--red), #f97316); }
|
||||||
|
.audit-score-num {
|
||||||
|
font-family: var(--mono); font-size: 12px; font-weight: 700;
|
||||||
|
min-width: 36px; text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AUDIT CHECKS LIST ───────────────────────────────────────── */
|
||||||
|
.audit-checks-label {
|
||||||
|
font-family: var(--mono); font-size: 9px; font-weight: 700;
|
||||||
|
letter-spacing: 0.12em; color: var(--text3);
|
||||||
|
text-transform: uppercase; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.audit-check-item {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 9px 12px; border-radius: 8px; margin-bottom: 4px;
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.audit-check-item:hover { background: var(--bg4); }
|
||||||
|
.audit-check-icon {
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; flex-shrink: 0; margin-top: 1px;
|
||||||
|
}
|
||||||
|
.audit-check-icon.pass { background: rgba(34,197,94,0.15); color: var(--green); }
|
||||||
|
.audit-check-icon.warn { background: rgba(245,158,11,0.15); color: var(--yellow); }
|
||||||
|
.audit-check-icon.fail { background: rgba(239,68,68,0.15); color: var(--red); }
|
||||||
|
|
||||||
|
.audit-check-body { flex: 1; min-width: 0; }
|
||||||
|
.audit-check-name { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||||
|
.audit-check-detail { font-size: 11px; color: var(--text3); font-family: var(--mono); margin-top: 2px; word-break: break-word; }
|
||||||
|
|
||||||
|
.audit-sha {
|
||||||
|
margin-top: 14px; padding: 10px 14px;
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; font-family: var(--mono); font-size: 11px;
|
||||||
|
color: var(--text3); word-break: break-all;
|
||||||
|
}
|
||||||
|
.audit-sha span { color: var(--cyan); }
|
||||||
|
|
||||||
|
/* ── AUDIT summary ─────────────────────────────────────────── */
|
||||||
|
.audit-summary-box {
|
||||||
|
margin-top: 14px; padding: 12px 14px;
|
||||||
|
background: var(--surface2); border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 12px; color: var(--text2); line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DETAILS MODAL specific ──────────────────────────────────── */
|
||||||
|
.details-grid {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.details-card {
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.details-card-label {
|
||||||
|
font-family: var(--mono); font-size: 9px; font-weight: 700;
|
||||||
|
letter-spacing: 0.12em; color: var(--text3); text-transform: uppercase; margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.details-card-value {
|
||||||
|
font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--text);
|
||||||
|
word-break: break-all; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.details-card-value.accent { color: var(--accent2); }
|
||||||
|
.details-card-value.green { color: var(--green); }
|
||||||
|
.details-card-value.yellow { color: var(--yellow); }
|
||||||
|
|
||||||
|
.details-path-row {
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 12px 14px; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.details-path-label {
|
||||||
|
font-family: var(--mono); font-size: 9px; font-weight: 700;
|
||||||
|
letter-spacing: 0.12em; color: var(--text3); text-transform: uppercase; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.details-path-value {
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--cyan); word-break: break-all;
|
||||||
|
}
|
||||||
|
.details-sha-row {
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 12px 14px; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.details-badge-row {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px;
|
||||||
|
}
|
||||||
|
.details-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 4px 10px; border-radius: 20px; font-family: var(--mono);
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.details-badge.ok { background: rgba(34,197,94,0.1); color: var(--green); border: 1px solid rgba(34,197,94,0.2); }
|
||||||
|
.details-badge.warn { background: rgba(245,158,11,0.1); color: var(--yellow); border: 1px solid rgba(245,158,11,0.2); }
|
||||||
|
.details-badge.info { background: rgba(96,165,250,0.1); color: var(--accent2);border: 1px solid rgba(96,165,250,0.2); }
|
||||||
|
|
||||||
|
/* ── CLOUD AUDIT specifics ───────────────────────────────────── */
|
||||||
|
.r2-check-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 12px; border-radius: 8px; margin-bottom: 4px;
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--text2);
|
||||||
|
}
|
||||||
|
.r2-check-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── R2 toast ─────────────────────────────────────────────── */
|
||||||
|
#r2-toast {
|
||||||
|
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-size: 12px; font-family: var(--mono); color: var(--text2);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
min-width: 290px;
|
||||||
|
animation: slideUpModal 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{# ── Manual Backup ── #}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
|
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
|
||||||
@@ -20,41 +291,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Available Backups ── #}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-database"></i> Available Backups</div>
|
<div class="card-title"><i class="fas fa-database"></i> Available Backups</div>
|
||||||
<button class="btn btn-ghost btn-sm" onclick="refreshBackupsList()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
<button class="btn btn-ghost btn-sm" onclick="refreshBackupsList()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="two-col">
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
|
||||||
|
|
||||||
|
{# LOCAL #}
|
||||||
<div>
|
<div>
|
||||||
<div class="section-header"><div class="section-title">🖥️ MAIN SERVER</div><span class="card-meta">/root/backups/</span></div>
|
<div class="section-header">
|
||||||
|
<div class="section-title">🖥️ MAIN SERVER</div>
|
||||||
|
<span class="card-meta">/root/backups/</span>
|
||||||
|
</div>
|
||||||
<div class="backup-list" id="local-backup-list">
|
<div class="backup-list" id="local-backup-list">
|
||||||
{% for b in backups %}
|
{% for b in backups %}
|
||||||
<div class="backup-item"><span class="backup-name">{{ b }}</span><div class="backup-actions"><button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button><button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button><button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b }}',this)"><i class="fas fa-trash"></i></button></div></div>
|
<div class="backup-item">
|
||||||
|
<span class="backup-name">{{ b }}</span>
|
||||||
|
<div class="backup-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('local','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('{{ b }}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b }}',this)"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>{% endfor %}
|
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# VM #}
|
||||||
<div>
|
<div>
|
||||||
<div class="section-header"><div class="section-title">💾 VM SERVER</div><span class="card-meta">/backups/main-server/</span></div>
|
<div class="section-header">
|
||||||
|
<div class="section-title">💾 VM SERVER</div>
|
||||||
|
<span class="card-meta">/backups/main-server/</span>
|
||||||
|
</div>
|
||||||
<div class="backup-list" id="vm-backup-list">
|
<div class="backup-list" id="vm-backup-list">
|
||||||
{% for b in vm_backups %}
|
{% for b in vm_backups %}
|
||||||
<div class="backup-item"><span class="backup-name">{{ b }}</span><div class="backup-actions"><button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button><button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button><button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b }}',this)"><i class="fas fa-trash"></i></button></div></div>
|
<div class="backup-item">
|
||||||
|
<span class="backup-name">{{ b }}</span>
|
||||||
|
<div class="backup-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('vm','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b }}',this)"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>{% endfor %}
|
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# CLOUD R2 #}
|
||||||
|
<div>
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">☁️ CLOUD (R2)</div>
|
||||||
|
<span class="card-meta">navitrends-backups</span>
|
||||||
|
</div>
|
||||||
|
<div class="backup-list" id="cloud-backup-list">
|
||||||
|
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="audit-modal" class="modal-overlay" style="display:none;" onclick="closeAuditModal(event)">
|
{# ── Backup History ── #}
|
||||||
<div class="modal-box" onclick="event.stopPropagation()">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="modal-title"><i class="fas fa-shield-halved" style="color:var(--cyan);"></i> Backup Health Audit</div>
|
|
||||||
<button class="modal-close" onclick="closeAuditModal()" title="Close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div id="audit-modal-content"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top:0;">
|
<div class="card" style="margin-top:0;">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-clock-rotate-left"></i> Backup History</div>
|
<div class="card-title"><i class="fas fa-clock-rotate-left"></i> Backup History</div>
|
||||||
@@ -62,4 +365,493 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="backup-history-list"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>
|
<div id="backup-history-list"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{# ══════════════════════════════════════════════════════════════
|
||||||
|
MODALS
|
||||||
|
══════════════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
{# ── Local/VM Audit modal ── #}
|
||||||
|
<div id="audit-modal" class="modal-overlay" style="display:none;" onclick="closeAuditModal(event)">
|
||||||
|
<div class="modal-box" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">
|
||||||
|
<i class="fas fa-shield-halved" style="color:var(--cyan);"></i>
|
||||||
|
Backup Health Audit
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeAuditModal()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="audit-modal-content">
|
||||||
|
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Cloud (R2) Audit modal ── #}
|
||||||
|
<div id="r2-audit-modal" class="modal-overlay" style="display:none;" onclick="closeR2AuditModal(event)">
|
||||||
|
<div class="modal-box" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">
|
||||||
|
<i class="fas fa-shield-halved" style="color:var(--cyan);"></i>
|
||||||
|
Cloud Backup Audit — R2
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeR2AuditModal()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="r2-audit-content">
|
||||||
|
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Details modal ── #}
|
||||||
|
<div id="details-modal" class="modal-overlay" style="display:none;" onclick="closeDetailsModal(event)">
|
||||||
|
<div class="modal-box" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">
|
||||||
|
<i class="fas fa-circle-info" style="color:var(--accent2);"></i>
|
||||||
|
Backup Details
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeDetailsModal()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="details-modal-content">
|
||||||
|
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading details…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── R2 upload toast ── #}
|
||||||
|
<div id="r2-toast" style="display:none;">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
||||||
|
<i class="fas fa-cloud-upload-alt" style="color:var(--accent2);font-size:16px;"></i>
|
||||||
|
<span id="r2-toast-msg" style="flex:1;font-size:12px;">Uploading to R2…</span>
|
||||||
|
</div>
|
||||||
|
<div style="height:5px;background:var(--border2);border-radius:10px;overflow:hidden;">
|
||||||
|
<div id="r2-toast-bar" style="height:100%;background:linear-gradient(90deg,var(--accent2),var(--cyan));width:0%;transition:width 0.4s ease;border-radius:10px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// HELPERS
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function _statusTier(status) {
|
||||||
|
if (status === 'healthy') return 'healthy';
|
||||||
|
if (status === 'warning') return 'warning';
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
function _statusIcon(status) {
|
||||||
|
if (status === 'healthy') return 'fa-circle-check';
|
||||||
|
if (status === 'warning') return 'fa-triangle-exclamation';
|
||||||
|
return 'fa-circle-xmark';
|
||||||
|
}
|
||||||
|
function _checkIcon(s) {
|
||||||
|
if (s === 'pass') return '✓';
|
||||||
|
if (s === 'warn') return '!';
|
||||||
|
return '✗';
|
||||||
|
}
|
||||||
|
function _scoreClass(score) {
|
||||||
|
if (score >= 75) return '';
|
||||||
|
if (score >= 50) return 'warn';
|
||||||
|
return 'crit';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// AUDIT — LOCAL / VM
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function renderAuditModal(report) {
|
||||||
|
const tier = report.health_tier || _statusTier(report.status || '');
|
||||||
|
const label = report.health_label || report.status || 'Unknown';
|
||||||
|
const score = typeof report.score === 'number' ? report.score : null;
|
||||||
|
const scoreStr = score !== null ? score + '%' : '—';
|
||||||
|
const scoreCls = score !== null ? _scoreClass(score) : 'crit';
|
||||||
|
|
||||||
|
let checksHtml = '';
|
||||||
|
(report.checks || []).forEach(c => {
|
||||||
|
checksHtml += `
|
||||||
|
<div class="audit-check-item">
|
||||||
|
<div class="audit-check-icon ${c.status}">${_checkIcon(c.status)}</div>
|
||||||
|
<div class="audit-check-body">
|
||||||
|
<div class="audit-check-name">${c.name}</div>
|
||||||
|
${c.detail ? `<div class="audit-check-detail">${c.detail}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="audit-status-banner ${tier}">
|
||||||
|
<div class="audit-status-icon">
|
||||||
|
<i class="fas ${_statusIcon(tier)}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="audit-status-info">
|
||||||
|
<div class="audit-status-label">${label}</div>
|
||||||
|
<div class="audit-status-file">${report.backup_file || ''}</div>
|
||||||
|
</div>
|
||||||
|
${report.file_size_display ? `<div class="audit-status-size">${report.file_size_display}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${score !== null ? `
|
||||||
|
<div class="audit-score-row">
|
||||||
|
<div style="font-family:var(--mono);font-size:11px;color:var(--text3);">Health Score</div>
|
||||||
|
<div class="audit-score-bar-wrap">
|
||||||
|
<div class="audit-score-bar-fill ${scoreCls}" style="width:${score}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-score-num" style="color:${score>=75?'var(--green)':score>=50?'var(--yellow)':'var(--red)'}">${scoreStr}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${report.checks && report.checks.length ? `
|
||||||
|
<div class="audit-checks-label">Checks</div>
|
||||||
|
${checksHtml}` : ''}
|
||||||
|
|
||||||
|
${report.summary ? `
|
||||||
|
<div class="audit-summary-box">
|
||||||
|
<i class="fas fa-info-circle" style="color:var(--accent2);margin-right:6px;"></i>${report.summary}
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${report.stored_hash || report.sha256 ? `
|
||||||
|
<div class="audit-sha">
|
||||||
|
<span style="color:var(--text3);">SHA256 </span>
|
||||||
|
<span>${report.stored_hash || report.sha256}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditBackup(source, filename, btn) {
|
||||||
|
const modal = document.getElementById('audit-modal');
|
||||||
|
const content = document.getElementById('audit-modal-content');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/backups/audit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({source, backup_file: filename})
|
||||||
|
});
|
||||||
|
const report = await res.json();
|
||||||
|
content.innerHTML = renderAuditModal(report);
|
||||||
|
// animate score bar
|
||||||
|
setTimeout(() => {
|
||||||
|
const bar = content.querySelector('.audit-score-bar-fill');
|
||||||
|
if (bar) bar.style.width = (report.score || 0) + '%';
|
||||||
|
}, 80);
|
||||||
|
} catch(e) {
|
||||||
|
content.innerHTML = `<div style="color:var(--red);font-family:var(--mono);font-size:12px;padding:16px;">❌ Audit request failed: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAuditModal(event) {
|
||||||
|
if (!event || event.target === document.getElementById('audit-modal')) {
|
||||||
|
document.getElementById('audit-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// AUDIT — CLOUD R2
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function auditCloudBackup(key, btn) {
|
||||||
|
const modal = document.getElementById('r2-audit-modal');
|
||||||
|
const content = document.getElementById('r2-audit-content');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/audit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({key})
|
||||||
|
});
|
||||||
|
const report = await res.json();
|
||||||
|
|
||||||
|
const tier = _statusTier(report.status);
|
||||||
|
const score = report.score || null;
|
||||||
|
const scoreCls = score !== null ? _scoreClass(score) : 'crit';
|
||||||
|
|
||||||
|
let checksHtml = '';
|
||||||
|
(report.checks || []).forEach(c => {
|
||||||
|
const icon = c.startsWith('✅') ? 'ok' : c.startsWith('⚠️') ? 'warn' : 'fail';
|
||||||
|
checksHtml += `<div class="r2-check-row">
|
||||||
|
<span class="r2-check-icon">${c.startsWith('✅') ? '✅' : c.startsWith('⚠️') ? '⚠️' : '❌'}</span>
|
||||||
|
<span>${c.replace(/^[✅⚠️❌]\s*/,'')}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="audit-status-banner ${tier}">
|
||||||
|
<div class="audit-status-icon"><i class="fas ${_statusIcon(tier)}"></i></div>
|
||||||
|
<div class="audit-status-info">
|
||||||
|
<div class="audit-status-label">${report.status || 'Unknown'}</div>
|
||||||
|
<div class="audit-status-file">${key}</div>
|
||||||
|
</div>
|
||||||
|
${report.size_human ? `<div class="audit-status-size">${report.size_human}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${score !== null ? `
|
||||||
|
<div class="audit-score-row">
|
||||||
|
<div style="font-family:var(--mono);font-size:11px;color:var(--text3);">Health Score</div>
|
||||||
|
<div class="audit-score-bar-wrap">
|
||||||
|
<div class="audit-score-bar-fill ${scoreCls}" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-score-num" style="color:${score>=75?'var(--green)':score>=50?'var(--yellow)':'var(--red)'}">${score}%</div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${checksHtml ? `<div class="audit-checks-label">Checks</div>${checksHtml}` : ''}
|
||||||
|
|
||||||
|
${report.warnings && report.warnings.length ? `
|
||||||
|
<div class="audit-checks-label" style="color:var(--yellow);margin-top:12px;">Warnings</div>
|
||||||
|
${report.warnings.map(w=>`<div class="r2-check-row" style="border-color:rgba(245,158,11,0.2);">
|
||||||
|
<span class="r2-check-icon">⚠️</span><span style="color:var(--yellow);">${w}</span>
|
||||||
|
</div>`).join('')}` : ''}
|
||||||
|
|
||||||
|
${report.errors && report.errors.length ? `
|
||||||
|
<div class="audit-checks-label" style="color:var(--red);margin-top:12px;">Errors</div>
|
||||||
|
${report.errors.map(e=>`<div class="r2-check-row" style="border-color:rgba(239,68,68,0.2);">
|
||||||
|
<span class="r2-check-icon">❌</span><span style="color:var(--red);">${e}</span>
|
||||||
|
</div>`).join('')}` : ''}
|
||||||
|
|
||||||
|
${report.stored_hash ? `<div class="audit-sha"><span style="color:var(--text3);">SHA256 </span><span>${report.stored_hash}</span></div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const bar = content.querySelector('.audit-score-bar-fill');
|
||||||
|
if (bar) bar.style.width = (score || 0) + '%';
|
||||||
|
}, 80);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
content.innerHTML = `<div style="color:var(--red);font-family:var(--mono);font-size:12px;padding:16px;">❌ Audit request failed: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeR2AuditModal(event) {
|
||||||
|
if (!event || event.target === document.getElementById('r2-audit-modal')) {
|
||||||
|
document.getElementById('r2-audit-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// DETAILS MODAL
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function showBackupDetails(source, filename, btn) {
|
||||||
|
const modal = document.getElementById('details-modal');
|
||||||
|
const content = document.getElementById('details-modal-content');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading details…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/backups/details', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({source, backup_file: filename})
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
|
||||||
|
// Parse date from filename: myapps-backup-YYYYMMDD_HHMMSS.tar.gz
|
||||||
|
const m = filename.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
|
||||||
|
let dateStr = d.created_at || '—';
|
||||||
|
if (!d.created_at && m) {
|
||||||
|
dateStr = `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]} UTC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = source === 'local'
|
||||||
|
? `/root/backups/${filename}`
|
||||||
|
: `/backups/main-server/${filename}`;
|
||||||
|
|
||||||
|
const hasSha = d.sha256 && d.sha256 !== 'none';
|
||||||
|
const ageDisplay = d.age_days !== undefined
|
||||||
|
? (d.age_days === 0 ? 'Today' : d.age_days === 1 ? '1 day ago' : `${d.age_days} days ago`)
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="details-path-row">
|
||||||
|
<div class="details-path-label">File Path</div>
|
||||||
|
<div class="details-path-value">${d.path || path}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details-grid">
|
||||||
|
<div class="details-card">
|
||||||
|
<div class="details-card-label">File Size</div>
|
||||||
|
<div class="details-card-value accent">${d.size_human || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="details-card">
|
||||||
|
<div class="details-card-label">Created</div>
|
||||||
|
<div class="details-card-value" style="font-size:11px;">${dateStr}</div>
|
||||||
|
</div>
|
||||||
|
<div class="details-card">
|
||||||
|
<div class="details-card-label">Age</div>
|
||||||
|
<div class="details-card-value ${d.age_days > 7 ? 'yellow' : 'green'}">${ageDisplay}</div>
|
||||||
|
</div>
|
||||||
|
<div class="details-card">
|
||||||
|
<div class="details-card-label">Source</div>
|
||||||
|
<div class="details-card-value">${source === 'local' ? '🖥️ Main Server' : '💾 VM Server'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details-sha-row">
|
||||||
|
<div class="details-path-label">SHA256 Checksum</div>
|
||||||
|
${hasSha
|
||||||
|
? `<div style="font-family:var(--mono);font-size:11px;color:var(--cyan);word-break:break-all;margin-top:4px;">${d.sha256}</div>`
|
||||||
|
: `<div style="font-family:var(--mono);font-size:11px;color:var(--text3);margin-top:4px;">No .sha256 sidecar — run a new backup to generate checksums</div>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details-badge-row">
|
||||||
|
${hasSha ? `<span class="details-badge ok"><i class="fas fa-check"></i> Checksum present</span>` : `<span class="details-badge warn"><i class="fas fa-triangle-exclamation"></i> No checksum</span>`}
|
||||||
|
${d.size_bytes && d.size_bytes > 50*1024*1024 ? `<span class="details-badge ok"><i class="fas fa-check"></i> Size OK</span>` : `<span class="details-badge warn"><i class="fas fa-triangle-exclamation"></i> Small file</span>`}
|
||||||
|
<span class="details-badge info"><i class="fas fa-file-zipper"></i> tar.gz</span>
|
||||||
|
${d.on_cloud ? `<span class="details-badge ok"><i class="fas fa-cloud"></i> Synced to R2</span>` : `<span class="details-badge info"><i class="fas fa-cloud"></i> Not in R2</span>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch(e) {
|
||||||
|
content.innerHTML = `<div style="color:var(--red);font-family:var(--mono);font-size:12px;padding:16px;">❌ Failed to load details: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailsModal(event) {
|
||||||
|
if (!event || event.target === document.getElementById('details-modal')) {
|
||||||
|
document.getElementById('details-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// CLOUD BACKUP COLUMN
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function loadCloudBackupsColumn() {
|
||||||
|
const list = document.getElementById('cloud-backup-list');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/backups');
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.backups || data.backups.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state"><i class="fas fa-cloud"></i> No cloud backups yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const archives = data.backups.filter(b => b.name.endsWith('.tar.gz'));
|
||||||
|
list.innerHTML = archives.map(b => `
|
||||||
|
<div class="backup-item">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;">
|
||||||
|
<span class="backup-name" style="font-size:11px;">
|
||||||
|
<i class="fas fa-cloud" style="color:var(--accent2);font-size:10px;margin-right:4px;"></i>${b.name}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:10px;color:var(--text3);font-family:var(--mono);">${b.size_human} · ${b.last_modified_str}</span>
|
||||||
|
</div>
|
||||||
|
<div class="backup-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditCloudBackup('${b.key}', this)" title="Audit R2 integrity">
|
||||||
|
<i class="fas fa-shield-check"></i> Audit
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="restoreFromCloud('${b.key}','${b.name}')">↩ Restore</button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteCloudBackup('${b.key}',this)"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch(e) {
|
||||||
|
list.innerHTML = `<div class="empty-state" style="color:var(--red);">Failed to load R2 backups</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// QUICK UPLOAD TO R2
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function quickUploadR2(filename, btn) {
|
||||||
|
const toast = document.getElementById('r2-toast');
|
||||||
|
const toastMsg = document.getElementById('r2-toast-msg');
|
||||||
|
const toastBar = document.getElementById('r2-toast-bar');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
toast.style.display = 'block';
|
||||||
|
toastMsg.textContent = `Uploading ${filename}…`;
|
||||||
|
toastBar.style.width = '0%';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({backup_file: filename, source: 'local'})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
toastMsg.textContent = '❌ ' + data.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
|
||||||
|
setTimeout(() => toast.style.display = 'none', 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = data.job_id;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const s = await fetch(`/api/cloud/r2/upload/status/${jobId}`);
|
||||||
|
const job = await s.json();
|
||||||
|
toastBar.style.width = job.progress + '%';
|
||||||
|
toastMsg.textContent = job.progress < 100
|
||||||
|
? `Uploading ${filename}… ${job.progress}%`
|
||||||
|
: `✅ Uploaded to R2!`;
|
||||||
|
if (job.status === 'done') {
|
||||||
|
clearInterval(poll);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
|
||||||
|
loadCloudBackupsColumn();
|
||||||
|
setTimeout(() => toast.style.display = 'none', 3000);
|
||||||
|
} else if (job.status === 'error') {
|
||||||
|
clearInterval(poll);
|
||||||
|
toastMsg.textContent = '❌ Upload failed';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
|
||||||
|
setTimeout(() => toast.style.display = 'none', 4000);
|
||||||
|
}
|
||||||
|
} catch(e) { clearInterval(poll); }
|
||||||
|
}, 1200);
|
||||||
|
} catch(e) {
|
||||||
|
toastMsg.textContent = '❌ ' + e.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
|
||||||
|
setTimeout(() => toast.style.display = 'none', 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// DELETE / RESTORE
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function restoreFromCloud(key, filename) {
|
||||||
|
window.location = `/restore?source=cloud&file=${encodeURIComponent(filename)}&key=${encodeURIComponent(key)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCloudBackup(key, btn) {
|
||||||
|
if (!confirm(`Delete from Cloudflare R2?\n${key}`)) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({key})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadCloudBackupsColumn();
|
||||||
|
} else {
|
||||||
|
alert('Delete failed: ' + data.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert(e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
document.addEventListener('DOMContentLoaded', loadCloudBackupsColumn);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
414
platform/templates/pages/cloud.html
Normal file
414
platform/templates/pages/cloud.html
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{# ── Connection status banner ─────────────────────────────────────────────── #}
|
||||||
|
<div class="card" id="r2-status-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title"><i class="fas fa-cloud"></i> Cloudflare R2 — Cloud Storage</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<span id="r2-conn-badge" class="badge" style="background:rgba(59,130,246,0.12);color:var(--accent2);">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> Checking…
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="testR2Connection()">
|
||||||
|
<i class="fas fa-plug"></i> Test Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row" id="r2-stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="r2-stat-objects">—</div>
|
||||||
|
<div class="stat-label">Cloud Backups</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="r2-stat-size">—</div>
|
||||||
|
<div class="stat-label">Total Cloud Size</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" style="font-size:13px;color:var(--text2);" id="r2-stat-bucket">{{ r2_bucket }}</div>
|
||||||
|
<div class="stat-label">Bucket Name</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" style="font-size:11px;color:var(--text3);">R2</div>
|
||||||
|
<div class="stat-label">Provider</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="r2-conn-msg" style="color:var(--text3);font-size:12px;margin-top:4px;font-family:var(--mono);padding:0 4px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Upload panel ─────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title"><i class="fas fa-cloud-upload-alt"></i> Upload Backup to R2</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text2);font-size:13px;margin-bottom:14px;">
|
||||||
|
Select a local or VM backup to push to Cloudflare R2 cold storage.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;max-width:640px;">
|
||||||
|
<div class="form-group" style="flex:1;min-width:260px;">
|
||||||
|
<label class="form-label">SOURCE BACKUP</label>
|
||||||
|
<select id="upload-backup-select" class="form-input">
|
||||||
|
{% if local_backups %}
|
||||||
|
<optgroup label="🖥️ Main Server">
|
||||||
|
{% for b in local_backups %}<option value="local::{{ b }}">{{ b }}</option>{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if vm_backups %}
|
||||||
|
<optgroup label="💾 VM Server">
|
||||||
|
{% for b in vm_backups %}<option value="vm::{{ b }}">{{ b }}</option>{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if not local_backups and not vm_backups %}
|
||||||
|
<option disabled>No backups found</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="uploadToR2()" id="r2-upload-btn"
|
||||||
|
{% if not r2_configured %}disabled title="R2 credentials not configured"{% endif %}>
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i> Upload to R2
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not r2_configured %}
|
||||||
|
<div style="margin-top:14px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);border-radius:8px;font-size:12px;color:var(--yellow);">
|
||||||
|
<i class="fas fa-triangle-exclamation"></i>
|
||||||
|
<strong>R2 credentials not configured.</strong>
|
||||||
|
Set <code>R2_ACCESS_KEY_ID</code> and <code>R2_SECRET_ACCESS_KEY</code> — see Setup Guide below.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="r2-upload-wrapper" style="display:none;margin-top:16px;">
|
||||||
|
<div class="card-header" style="margin-bottom:8px;">
|
||||||
|
<div class="card-title" style="font-size:13px;"><i class="fas fa-terminal"></i> Upload Log</div>
|
||||||
|
<span class="badge" id="r2-upload-badge" style="background:rgba(59,130,246,0.15);color:var(--accent2);">Running…</span>
|
||||||
|
</div>
|
||||||
|
<div id="r2-upload-log" class="log-console" style="max-height:200px;"></div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<div style="height:6px;background:var(--border);border-radius:4px;overflow:hidden;">
|
||||||
|
<div id="r2-progress-bar" style="height:100%;background:linear-gradient(90deg,var(--accent2),var(--cyan));width:0%;transition:width 0.4s ease;border-radius:4px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:4px;">
|
||||||
|
<span style="font-size:11px;color:var(--text3);font-family:var(--mono);" id="r2-upload-elapsed"></span>
|
||||||
|
<span style="font-size:11px;color:var(--accent2);font-family:var(--mono);" id="r2-progress-pct">0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Cloud backup list ─────────────────────────────────────────────────────── #}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title"><i class="fas fa-database"></i> Cloud Backups (R2)</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="loadR2Backups()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="r2-backup-list">
|
||||||
|
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Audit modal ── #}
|
||||||
|
<div id="r2-audit-modal" class="modal-overlay" style="display:none;" onclick="closeR2AuditModal(event)">
|
||||||
|
<div class="modal-box" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title"><i class="fas fa-shield-halved" style="color:var(--cyan);"></i> Cloud Backup Audit — R2</div>
|
||||||
|
<button class="modal-close" onclick="closeR2AuditModal()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="r2-audit-content"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Setup guide ───────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title"><i class="fas fa-book"></i> R2 Setup Guide</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="toggleSetupGuide()"><i class="fas fa-chevron-down" id="guide-chevron"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="setup-guide-body" style="display:none;">
|
||||||
|
<div class="log-console" style="max-height:none;font-size:12px;line-height:1.8;">
|
||||||
|
<span style="color:var(--cyan);"># ─── STEP 1: Create an R2 API Token ───────────────────────────────────────</span>
|
||||||
|
<span style="color:var(--text2);">1. Go to https://dash.cloudflare.com → R2 → "Manage R2 API Tokens"</span>
|
||||||
|
<span style="color:var(--text2);">2. Click "Create API token"</span>
|
||||||
|
<span style="color:var(--text2);">3. Set permissions: Object Read & Write</span>
|
||||||
|
<span style="color:var(--text2);">4. Specify bucket (or leave "All buckets")</span>
|
||||||
|
<span style="color:var(--text2);">5. Copy Access Key ID and Secret Access Key</span>
|
||||||
|
|
||||||
|
<span style="color:var(--cyan);"># ─── STEP 2: Create credentials file on main server ───────────────────────</span>
|
||||||
|
<span style="color:var(--green);">cat > /root/.r2-credentials << 'EOF'</span>
|
||||||
|
<span style="color:var(--yellow);">export AWS_ACCESS_KEY_ID="your-access-key-id"</span>
|
||||||
|
<span style="color:var(--yellow);">export AWS_SECRET_ACCESS_KEY="your-secret-access-key"</span>
|
||||||
|
<span style="color:var(--yellow);">export R2_ACCOUNT_ID="35e00c230cc8066252a2d9890b69aea2"</span>
|
||||||
|
<span style="color:var(--yellow);">export R2_BUCKET_NAME="navitrends-backups"</span>
|
||||||
|
<span style="color:var(--green);">EOF</span>
|
||||||
|
<span style="color:var(--green);">chmod 600 /root/.r2-credentials</span>
|
||||||
|
|
||||||
|
<span style="color:var(--cyan);"># ─── STEP 3: Set same vars for the platform (Flask app) ───────────────────</span>
|
||||||
|
<span style="color:var(--text2);">Add to your systemd service or startup script:</span>
|
||||||
|
<span style="color:var(--green);">Environment="R2_ACCESS_KEY_ID=your-key"</span>
|
||||||
|
<span style="color:var(--green);">Environment="R2_SECRET_ACCESS_KEY=your-secret"</span>
|
||||||
|
<span style="color:var(--green);">Environment="R2_BUCKET_NAME=navitrends-backups"</span>
|
||||||
|
|
||||||
|
<span style="color:var(--cyan);"># ─── STEP 4: Install boto3 ────────────────────────────────────────────────</span>
|
||||||
|
<span style="color:var(--green);">pip install boto3 --break-system-packages</span>
|
||||||
|
|
||||||
|
<span style="color:var(--cyan);"># ─── STEP 5: (Optional) Install AWS CLI for shell-level uploads ───────────</span>
|
||||||
|
<span style="color:var(--green);">pip install awscli --break-system-packages</span>
|
||||||
|
<span style="color:var(--green);">aws configure set default.s3.multipart_threshold 64MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleSetupGuide() {
|
||||||
|
const body = document.getElementById('setup-guide-body');
|
||||||
|
const icon = document.getElementById('guide-chevron');
|
||||||
|
const open = body.style.display === 'none';
|
||||||
|
body.style.display = open ? '' : 'none';
|
||||||
|
icon.className = open ? 'fas fa-chevron-up' : 'fas fa-chevron-down';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testR2Connection() {
|
||||||
|
const badge = document.getElementById('r2-conn-badge');
|
||||||
|
const msg = document.getElementById('r2-conn-msg');
|
||||||
|
badge.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing…';
|
||||||
|
badge.style.cssText = 'background:rgba(59,130,246,0.12);color:var(--accent2);';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/test');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
badge.innerHTML = '✅ Connected';
|
||||||
|
badge.style.cssText = 'background:rgba(34,197,94,0.12);color:var(--green);';
|
||||||
|
} else {
|
||||||
|
badge.innerHTML = '❌ Disconnected';
|
||||||
|
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
|
||||||
|
}
|
||||||
|
msg.textContent = data.message || '';
|
||||||
|
} catch(e) {
|
||||||
|
badge.innerHTML = '❌ Error';
|
||||||
|
msg.textContent = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadR2Stats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/stats');
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('r2-stat-objects').textContent = data.count ?? '—';
|
||||||
|
document.getElementById('r2-stat-size').textContent = data.total_size_human ?? '—';
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadR2Backups() {
|
||||||
|
const list = document.getElementById('r2-backup-list');
|
||||||
|
list.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/backups');
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.backups || data.backups.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state"><i class="fas fa-cloud"></i> No cloud backups yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = data.backups.map(b => `
|
||||||
|
<div class="backup-item">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px;">
|
||||||
|
<span class="backup-name">
|
||||||
|
<i class="fas fa-cloud" style="color:var(--accent2);font-size:11px;margin-right:6px;"></i>${b.name}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:11px;color:var(--text3);font-family:var(--mono);">
|
||||||
|
${b.size_human} · ${b.last_modified_str}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="backup-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="auditR2Backup('${b.key}', this)" title="Audit integrity">
|
||||||
|
<i class="fas fa-shield-check"></i> Audit
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="restoreFromR2('${b.key}','${b.name}')">
|
||||||
|
↩ Restore
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteR2Backup('${b.key}', this)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch(e) {
|
||||||
|
list.innerHTML = `<div class="empty-state" style="color:#f87171;"><i class="fas fa-circle-exclamation"></i> ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function auditR2Backup(key, btn) {
|
||||||
|
const modal = document.getElementById('r2-audit-modal');
|
||||||
|
const content = document.getElementById('r2-audit-content');
|
||||||
|
modal.style.display = '';
|
||||||
|
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/audit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({key})
|
||||||
|
});
|
||||||
|
const report = await res.json();
|
||||||
|
|
||||||
|
const statusColor = report.status === 'healthy' ? 'var(--green)'
|
||||||
|
: report.status === 'warning' ? 'var(--yellow)'
|
||||||
|
: '#f87171';
|
||||||
|
const statusIcon = report.status === 'healthy' ? 'fa-circle-check'
|
||||||
|
: report.status === 'warning' ? 'fa-triangle-exclamation'
|
||||||
|
: 'fa-circle-xmark';
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div style="margin-bottom:14px;padding:10px 14px;background:var(--bg2);border-radius:8px;display:flex;align-items:center;gap:10px;">
|
||||||
|
<i class="fas ${statusIcon}" style="color:${statusColor};font-size:18px;"></i>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;color:${statusColor};text-transform:uppercase;font-size:12px;">${report.status}</div>
|
||||||
|
<div style="font-size:11px;color:var(--text3);font-family:var(--mono);">${key}</div>
|
||||||
|
</div>
|
||||||
|
${report.size_human ? `<div style="margin-left:auto;font-size:12px;color:var(--text2);">${report.size_human}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${report.checks && report.checks.length ? `
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em;">Checks</div>
|
||||||
|
${report.checks.map(c => `<div style="font-size:12px;padding:3px 0;font-family:var(--mono);">${c}</div>`).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${report.warnings && report.warnings.length ? `
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<div style="font-size:11px;color:var(--yellow);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em;">Warnings</div>
|
||||||
|
${report.warnings.map(w => `<div style="font-size:12px;padding:3px 0;font-family:var(--mono);color:var(--yellow);">${w}</div>`).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${report.errors && report.errors.length ? `
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<div style="font-size:11px;color:#f87171;margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em;">Errors</div>
|
||||||
|
${report.errors.map(e => `<div style="font-size:12px;padding:3px 0;font-family:var(--mono);color:#f87171;">${e}</div>`).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${report.stored_hash ? `
|
||||||
|
<div style="font-size:11px;color:var(--text3);margin-top:8px;font-family:var(--mono);">
|
||||||
|
SHA256: ${report.stored_hash}
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
} catch(e) {
|
||||||
|
content.innerHTML = `<div style="color:#f87171;">❌ Audit request failed: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeR2AuditModal(event) {
|
||||||
|
if (!event || event.target === document.getElementById('r2-audit-modal')) {
|
||||||
|
document.getElementById('r2-audit-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFromR2(key, filename) {
|
||||||
|
window.location = `/restore?source=cloud&file=${encodeURIComponent(filename)}&key=${encodeURIComponent(key)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteR2Backup(key, btn) {
|
||||||
|
if (!confirm(`Delete from R2?\n${key}`)) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({key})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadR2Backups();
|
||||||
|
loadR2Stats();
|
||||||
|
} else {
|
||||||
|
alert('Delete failed: ' + data.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert(e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToR2() {
|
||||||
|
const sel = document.getElementById('upload-backup-select');
|
||||||
|
const val = sel.value;
|
||||||
|
if (!val) return;
|
||||||
|
const [source, file] = val.split('::');
|
||||||
|
|
||||||
|
const btn = document.getElementById('r2-upload-btn');
|
||||||
|
const wrapper = document.getElementById('r2-upload-wrapper');
|
||||||
|
const logEl = document.getElementById('r2-upload-log');
|
||||||
|
const badge = document.getElementById('r2-upload-badge');
|
||||||
|
const bar = document.getElementById('r2-progress-bar');
|
||||||
|
const pct = document.getElementById('r2-progress-pct');
|
||||||
|
const elapsed = document.getElementById('r2-upload-elapsed');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
wrapper.style.display = '';
|
||||||
|
logEl.innerHTML = '';
|
||||||
|
badge.textContent = 'Starting…';
|
||||||
|
bar.style.width = '0%';
|
||||||
|
pct.textContent = '0%';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({backup_file: file, source})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
badge.textContent = '❌ Error';
|
||||||
|
logEl.innerHTML += `<div style="color:#f87171;">${data.message}</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = data.job_id;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const s = await fetch(`/api/cloud/r2/upload/status/${jobId}`);
|
||||||
|
const job = await s.json();
|
||||||
|
|
||||||
|
logEl.innerHTML = job.log.map(l =>
|
||||||
|
`<div>${l.replace(/✅/g,'<span style="color:var(--green)">✅</span>')
|
||||||
|
.replace(/❌/g,'<span style="color:#f87171;">❌</span>')
|
||||||
|
.replace(/⬆️/g,'<span style="color:var(--accent2)">⬆️</span>')}</div>`
|
||||||
|
).join('');
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
|
||||||
|
bar.style.width = job.progress + '%';
|
||||||
|
pct.textContent = job.progress + '%';
|
||||||
|
elapsed.textContent = `Elapsed: ${job.elapsed}s`;
|
||||||
|
|
||||||
|
if (job.status === 'done') {
|
||||||
|
clearInterval(poll);
|
||||||
|
badge.textContent = '✅ Uploaded';
|
||||||
|
badge.style.cssText = 'background:rgba(34,197,94,0.12);color:var(--green);';
|
||||||
|
btn.disabled = false;
|
||||||
|
loadR2Backups();
|
||||||
|
loadR2Stats();
|
||||||
|
} else if (job.status === 'error') {
|
||||||
|
clearInterval(poll);
|
||||||
|
badge.textContent = '❌ Failed';
|
||||||
|
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch(e) { clearInterval(poll); }
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
badge.textContent = '❌ Error';
|
||||||
|
logEl.innerHTML += `<div style="color:#f87171;">${e.message}</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
testR2Connection();
|
||||||
|
loadR2Stats();
|
||||||
|
loadR2Backups();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -46,6 +46,10 @@
|
|||||||
<div class="stat-card"><div class="stat-number" id="stat-users">{{ users|length }}</div><div class="stat-label">Linux Users</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-users">{{ users|length }}</div><div class="stat-label">Linux Users</div></div>
|
||||||
<div class="stat-card"><div class="stat-number" id="stat-local-bk">{{ backups|length }}</div><div class="stat-label">Local Backups</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-local-bk">{{ backups|length }}</div><div class="stat-label">Local Backups</div></div>
|
||||||
<div class="stat-card"><div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div><div class="stat-label">VM Backups</div></div>
|
<div class="stat-card"><div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div><div class="stat-label">VM Backups</div></div>
|
||||||
|
<div class="stat-card" style="cursor:pointer;" onclick="window.location='/cloud'">
|
||||||
|
<div class="stat-number" id="stat-cloud-bk" style="color:var(--accent2);">—</div>
|
||||||
|
<div class="stat-label">☁ Cloud Backups</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,16 +110,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not running_on_main %}
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// ── Helper: build a container row identical to the Jinja template ──
|
// ── Load cloud backup count async ──────────────────────────────────────────
|
||||||
|
async function loadCloudCount() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/stats');
|
||||||
|
const data = await res.json();
|
||||||
|
const el = document.getElementById('stat-cloud-bk');
|
||||||
|
if (el) el.textContent = data.count ?? '—';
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
loadCloudCount();
|
||||||
|
|
||||||
|
{% if not running_on_main %}
|
||||||
|
// ── Helper: build a container row ──────────────────────────────────────────
|
||||||
function buildRow(c) {
|
function buildRow(c) {
|
||||||
const isUp = c.status && c.status.includes('Up');
|
const isUp = c.status && c.status.includes('Up');
|
||||||
const badge = isUp
|
const badge = isUp
|
||||||
? `<span class="badge badge-run">Running</span>`
|
? `<span class="badge badge-run">Running</span>`
|
||||||
: `<span class="badge badge-stop">Stopped</span>`;
|
: `<span class="badge badge-stop">Stopped</span>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr data-ctr="${c.name}">
|
<tr data-ctr="${c.name}">
|
||||||
<td class="ct-name">${c.name}</td>
|
<td class="ct-name">${c.name}</td>
|
||||||
@@ -136,45 +150,31 @@
|
|||||||
<td class="col-extra app-extra ct-image" style="display:none;">${c.image || ''}</td>
|
<td class="col-extra app-extra ct-image" style="display:none;">${c.image || ''}</td>
|
||||||
<td class="col-extra app-extra ct-ports" style="display:none;">${c.ports || '—'}</td>
|
<td class="col-extra app-extra ct-ports" style="display:none;">${c.ports || '—'}</td>
|
||||||
<td><div class="action-btns">
|
<td><div class="action-btns">
|
||||||
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${c.name}','restart',this)">
|
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${c.name}','restart',this)"><i class="fas fa-rotate-right"></i></button>
|
||||||
<i class="fas fa-rotate-right"></i>
|
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('${c.name}','stop',this)"><i class="fas fa-stop"></i></button>
|
||||||
</button>
|
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('${c.name}','start',this)"><i class="fas fa-play"></i></button>
|
||||||
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('${c.name}','stop',this)">
|
|
||||||
<i class="fas fa-stop"></i>
|
|
||||||
</button>
|
|
||||||
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('${c.name}','start',this)">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
</button>
|
|
||||||
</div></td>
|
</div></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Populate metrics cards ──
|
|
||||||
function applySystem(s) {
|
function applySystem(s) {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
|
||||||
const cpu = parseFloat(s.cpu_pct) || 0;
|
const cpu = parseFloat(s.cpu_pct) || 0;
|
||||||
document.getElementById('m-cpu').innerHTML = `${cpu.toFixed(1)}<span>%</span>`;
|
document.getElementById('m-cpu').innerHTML = `${cpu.toFixed(1)}<span>%</span>`;
|
||||||
document.getElementById('m-mem').textContent = s.memory || '—';
|
document.getElementById('m-mem').textContent = s.memory || '—';
|
||||||
document.getElementById('m-disk').textContent = s.disk || '—';
|
document.getElementById('m-disk').textContent = s.disk || '—';
|
||||||
document.getElementById('m-load').textContent = s.load || '—';
|
document.getElementById('m-load').textContent = s.load || '—';
|
||||||
|
|
||||||
document.getElementById('g-cpu').style.width = `${Math.min(cpu, 100)}%`;
|
document.getElementById('g-cpu').style.width = `${Math.min(cpu, 100)}%`;
|
||||||
document.getElementById('g-mem').style.width = `${Math.min(parseFloat(s.mem_pct) || 0, 100)}%`;
|
document.getElementById('g-mem').style.width = `${Math.min(parseFloat(s.mem_pct) || 0, 100)}%`;
|
||||||
document.getElementById('g-disk').style.width = `${Math.min(parseFloat(s.disk_pct) || 0, 100)}%`;
|
document.getElementById('g-disk').style.width = `${Math.min(parseFloat(s.disk_pct) || 0, 100)}%`;
|
||||||
|
|
||||||
if (s.docker_v) {
|
if (s.docker_v) {
|
||||||
const meta = document.getElementById('overview-meta');
|
const meta = document.getElementById('overview-meta');
|
||||||
if (meta) meta.textContent = `Docker ${s.docker_v} · {{ main_server }}`;
|
if (meta) meta.textContent = `Docker ${s.docker_v} · {{ main_server }}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Populate overview stat numbers ──
|
|
||||||
function applyStats(data) {
|
function applyStats(data) {
|
||||||
const set = (id, val) => {
|
const set = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.textContent = val; };
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el && val !== undefined && val !== null) el.textContent = val;
|
|
||||||
};
|
|
||||||
set('stat-total', data.containers ? data.containers.length : undefined);
|
set('stat-total', data.containers ? data.containers.length : undefined);
|
||||||
set('stat-running', data.running_count);
|
set('stat-running', data.running_count);
|
||||||
set('stat-users', data.user_count);
|
set('stat-users', data.user_count);
|
||||||
@@ -182,52 +182,36 @@
|
|||||||
set('stat-vm-bk', data.vm_backups);
|
set('stat-vm-bk', data.vm_backups);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Populate the containers table ──
|
|
||||||
function applyContainers(containers) {
|
function applyContainers(containers) {
|
||||||
if (!containers || !containers.length) return;
|
if (!containers || !containers.length) return;
|
||||||
|
|
||||||
const tbody = document.getElementById('app-containers-body');
|
const tbody = document.getElementById('app-containers-body');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
tbody.innerHTML = containers.map(buildRow).join('');
|
tbody.innerHTML = containers.map(buildRow).join('');
|
||||||
|
|
||||||
// Re-apply column visibility in case "Show more" was already toggled
|
|
||||||
const extras = tbody.querySelectorAll('.app-extra');
|
const extras = tbody.querySelectorAll('.app-extra');
|
||||||
const btn = document.getElementById('app-toggle-btn');
|
const btn = document.getElementById('app-toggle-btn');
|
||||||
if (btn && btn.dataset.expanded === 'true') {
|
if (btn && btn.dataset.expanded === 'true') extras.forEach(el => el.style.display = '');
|
||||||
extras.forEach(el => el.style.display = '');
|
if (typeof refreshContainerStats === 'function') refreshContainerStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kick stats refresh if a global function exists (from base template)
|
|
||||||
if (typeof refreshContainerStats === 'function') {
|
|
||||||
refreshContainerStats();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main async loader ──
|
|
||||||
async function loadDashboardAsync() {
|
async function loadDashboardAsync() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/dashboard');
|
const res = await fetch('/api/dashboard');
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
applySystem(data.system);
|
applySystem(data.system);
|
||||||
applyStats(data);
|
applyStats(data);
|
||||||
applyContainers(data.containers);
|
applyContainers(data.containers);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[dashboard] async load failed:', err);
|
console.error('[dashboard] async load failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run immediately on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', loadDashboardAsync);
|
document.addEventListener('DOMContentLoaded', loadDashboardAsync);
|
||||||
} else {
|
} else {
|
||||||
loadDashboardAsync();
|
loadDashboardAsync();
|
||||||
}
|
}
|
||||||
|
{% endif %}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -10,24 +10,54 @@
|
|||||||
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
|
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
<label class="radio-card">
|
<label class="radio-card">
|
||||||
<input type="radio" name="backup_source" value="local" checked onchange="updateBackupList()">
|
<input type="radio" name="backup_source" value="local" checked onchange="nvUpdateSource()">
|
||||||
<div class="radio-body"><span class="radio-icon">🖥️</span><div><div class="radio-label">Main Server</div><div class="radio-desc"><code>/root/backups/</code></div></div></div>
|
<div class="radio-body"><span class="radio-icon">🖥️</span><div><div class="radio-label">Main Server</div><div class="radio-desc"><code>/root/backups/</code></div></div></div>
|
||||||
</label>
|
</label>
|
||||||
<label class="radio-card">
|
<label class="radio-card">
|
||||||
<input type="radio" name="backup_source" value="vm" onchange="updateBackupList()">
|
<input type="radio" name="backup_source" value="vm" onchange="nvUpdateSource()">
|
||||||
<div class="radio-body"><span class="radio-icon">💾</span><div><div class="radio-label">VM Backup Server</div><div class="radio-desc"><code>/backups/main-server/</code></div></div></div>
|
<div class="radio-body"><span class="radio-icon">💾</span><div><div class="radio-label">VM Backup Server</div><div class="radio-desc"><code>/backups/main-server/</code></div></div></div>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="backup_source" value="cloud" onchange="nvUpdateSource()">
|
||||||
|
<div class="radio-body"><span class="radio-icon">☁️</span><div><div class="radio-label">Cloudflare R2</div><div class="radio-desc">cloud storage</div></div></div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Separate selects — only one shown at a time #}
|
||||||
<div class="form-group" style="margin-top:14px; max-width:500px;">
|
<div class="form-group" style="margin-top:14px; max-width:500px;">
|
||||||
<label class="form-label">BACKUP FILE</label>
|
<label class="form-label">BACKUP FILE</label>
|
||||||
<select id="backup-file-select" class="form-input">
|
|
||||||
<optgroup label="Main Server" id="local-options">
|
{# Local select #}
|
||||||
{% for b in backups %}<option value="{{ b }}" data-source="local">{{ b }}</option>{% else %}<option disabled>No local backups</option>{% endfor %}
|
<select id="select-local" class="form-input">
|
||||||
</optgroup>
|
{% for b in backups %}
|
||||||
<optgroup label="VM Backups" id="vm-options" style="display:none;">
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
{% for b in vm_backups %}<option value="{{ b }}" data-source="vm">{{ b }}</option>{% else %}<option disabled>No VM backups</option>{% endfor %}
|
{% else %}
|
||||||
</optgroup>
|
<option disabled>No local backups</option>
|
||||||
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{# VM select #}
|
||||||
|
<select id="select-vm" class="form-input" style="display:none;">
|
||||||
|
{% for b in vm_backups %}
|
||||||
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
|
{% else %}
|
||||||
|
<option disabled>No VM backups</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{# Cloud select — populated via JS #}
|
||||||
|
<select id="select-cloud" class="form-input" style="display:none;">
|
||||||
|
<option disabled selected>Loading cloud backups…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Cloud info notice #}
|
||||||
|
<div id="cloud-notice" style="display:none;margin-top:10px;padding:10px 14px;
|
||||||
|
background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);
|
||||||
|
border-radius:8px;font-size:12px;color:var(--accent2);">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
The backup will be downloaded from R2 to the server first, then restored.
|
||||||
|
This may take a few minutes depending on the file size (typically 1–2 GB).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,7 +114,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<button class="btn btn-danger btn-lg" onclick="launchRestore()" id="restore-btn">
|
<button class="btn btn-danger btn-lg" onclick="nvLaunchRestore()" id="restore-btn">
|
||||||
<i class="fas fa-play"></i> Start Restore
|
<i class="fas fa-play"></i> Start Restore
|
||||||
</button>
|
</button>
|
||||||
<p style="color:var(--text3); font-size:12px; margin-top:8px;">⚠ Healthy running containers are skipped.</p>
|
<p style="color:var(--text3); font-size:12px; margin-top:8px;">⚠ Healthy running containers are skipped.</p>
|
||||||
@@ -100,7 +130,158 @@
|
|||||||
<div style="color:var(--text3);font-size:11px;margin-top:6px;font-family:var(--mono);" id="restore-elapsed"></div>
|
<div style="color:var(--text3);font-size:11px;margin-top:6px;font-family:var(--mono);" id="restore-elapsed"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.restorePrefill = {{ restore_prefill|tojson }};
|
window.restorePrefill = {{ restore_prefill|tojson }};
|
||||||
|
|
||||||
|
// ── Cloud backup data (key lookup) ────────────────────────────────────────────
|
||||||
|
let _cloudBackups = [];
|
||||||
|
|
||||||
|
async function loadCloudSelect() {
|
||||||
|
const sel = document.getElementById('select-cloud');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloud/r2/backups');
|
||||||
|
const data = await res.json();
|
||||||
|
// Only .tar.gz — no .sha256
|
||||||
|
_cloudBackups = (data.backups || []).filter(b => b.name.endsWith('.tar.gz'));
|
||||||
|
if (_cloudBackups.length === 0) {
|
||||||
|
sel.innerHTML = '<option disabled>No cloud backups found</option>';
|
||||||
|
} else {
|
||||||
|
sel.innerHTML = _cloudBackups.map(b =>
|
||||||
|
`<option value="${b.name}" data-key="${b.key}">${b.name} (${b.size_human})</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
sel.innerHTML = '<option disabled>Failed to load R2 backups</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Show/hide correct select based on source radio ────────────────────────────
|
||||||
|
function nvUpdateSource() {
|
||||||
|
const source = document.querySelector('input[name="backup_source"]:checked')?.value;
|
||||||
|
document.getElementById('select-local').style.display = source === 'local' ? '' : 'none';
|
||||||
|
document.getElementById('select-vm').style.display = source === 'vm' ? '' : 'none';
|
||||||
|
document.getElementById('select-cloud').style.display = source === 'cloud' ? '' : 'none';
|
||||||
|
document.getElementById('cloud-notice').style.display = source === 'cloud' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get currently selected file + source ─────────────────────────────────────
|
||||||
|
function nvGetSelection() {
|
||||||
|
const source = document.querySelector('input[name="backup_source"]:checked')?.value || 'local';
|
||||||
|
let file = '', key = '';
|
||||||
|
if (source === 'local') {
|
||||||
|
file = document.getElementById('select-local').value;
|
||||||
|
} else if (source === 'vm') {
|
||||||
|
file = document.getElementById('select-vm').value;
|
||||||
|
} else {
|
||||||
|
const sel = document.getElementById('select-cloud');
|
||||||
|
file = sel.value;
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
key = opt?.dataset?.key || ('backups/' + file);
|
||||||
|
}
|
||||||
|
return { source, file, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Launch restore ────────────────────────────────────────────────────────────
|
||||||
|
async function nvLaunchRestore() {
|
||||||
|
const { source, file, key } = nvGetSelection();
|
||||||
|
if (!file) { alert('Please select a backup file.'); return; }
|
||||||
|
|
||||||
|
const target = document.querySelector('input[name="restore_target"]:checked')?.value || 'local';
|
||||||
|
const authMethod = document.querySelector('input[name="auth_method"]:checked')?.value || 'key';
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
backup_source: source,
|
||||||
|
backup_file: file,
|
||||||
|
cloud_key: key,
|
||||||
|
target,
|
||||||
|
remote_ip: document.getElementById('remote-ip')?.value || '',
|
||||||
|
remote_port: document.getElementById('remote-port')?.value || '22',
|
||||||
|
remote_user: document.getElementById('remote-user')?.value || 'root',
|
||||||
|
auth_method: authMethod,
|
||||||
|
ssh_key_path: document.getElementById('ssh-key-path')?.value || '',
|
||||||
|
ssh_password: document.getElementById('ssh-password')?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn = document.getElementById('restore-btn');
|
||||||
|
const wrapper = document.getElementById('restore-log-wrapper');
|
||||||
|
const logEl = document.getElementById('restore-log');
|
||||||
|
const badge = document.getElementById('restore-status-badge');
|
||||||
|
const elapsed = document.getElementById('restore-elapsed');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
wrapper.style.display = '';
|
||||||
|
logEl.innerHTML = source === 'cloud'
|
||||||
|
? '<div style="color:var(--accent2)">☁️ Downloading backup from R2, please wait…</div>'
|
||||||
|
: '<div style="color:var(--accent2)">🚀 Starting restore…</div>';
|
||||||
|
badge.textContent = 'Running…';
|
||||||
|
badge.style.cssText = 'background:rgba(59,130,246,0.15);color:var(--accent2);';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/restore/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
logEl.innerHTML += `<div style="color:#f87171;">❌ ${data.error}</div>`;
|
||||||
|
badge.textContent = '❌ Error';
|
||||||
|
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = data.job_id;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const s = await fetch(`/restore/status/${jobId}`);
|
||||||
|
const job = await s.json();
|
||||||
|
logEl.innerHTML = job.log.map(l => `<div>${l}</div>`).join('');
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
elapsed.textContent = `Elapsed: ${job.elapsed}s`;
|
||||||
|
if (job.status === 'done') {
|
||||||
|
clearInterval(poll);
|
||||||
|
badge.textContent = '✅ Done';
|
||||||
|
badge.style.cssText = 'background:rgba(34,197,94,0.12);color:var(--green);';
|
||||||
|
btn.disabled = false;
|
||||||
|
} else if (job.status === 'error') {
|
||||||
|
clearInterval(poll);
|
||||||
|
badge.textContent = '❌ Failed';
|
||||||
|
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch(e) { clearInterval(poll); }
|
||||||
|
}, 1500);
|
||||||
|
} catch(e) {
|
||||||
|
logEl.innerHTML += `<div style="color:#f87171;">❌ ${e.message}</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadCloudSelect();
|
||||||
|
|
||||||
|
// Handle prefill from URL params (e.g. clicking Restore from backups page)
|
||||||
|
const pf = window.restorePrefill || {};
|
||||||
|
if (pf.source) {
|
||||||
|
const radio = document.querySelector(`input[name="backup_source"][value="${pf.source}"]`);
|
||||||
|
if (radio) { radio.checked = true; nvUpdateSource(); }
|
||||||
|
}
|
||||||
|
if (pf.file) {
|
||||||
|
// Pre-select file after a short delay to let cloud options load
|
||||||
|
setTimeout(() => {
|
||||||
|
const source = pf.source || 'local';
|
||||||
|
const selId = source === 'cloud' ? 'select-cloud' : source === 'vm' ? 'select-vm' : 'select-local';
|
||||||
|
const sel = document.getElementById(selId);
|
||||||
|
if (sel) {
|
||||||
|
for (let o of sel.options) {
|
||||||
|
if (o.value === pf.file) { o.selected = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
# =============================================
|
# =============================================
|
||||||
# backup-myapps.sh — Run on MAIN SERVER
|
# backup-myapps.sh — Run on MAIN SERVER
|
||||||
# Backs up: Frappe, Nextcloud, Mautic, n8n, Odoo
|
# Backs up: Frappe, Nextcloud, Mautic, n8n, Odoo
|
||||||
|
# Storage tiers:
|
||||||
|
# 1. Local → /root/backups/
|
||||||
|
# 2. VM → SSH tunnel → /backups/main-server/
|
||||||
|
# 3. Cloud → Cloudflare R2 (S3-compatible)
|
||||||
# Usage: ./backup-myapps.sh
|
# Usage: ./backup-myapps.sh
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
@@ -19,6 +23,20 @@ VM_PORT="2223"
|
|||||||
VM_KEY="/root/.ssh/contabo-key"
|
VM_KEY="/root/.ssh/contabo-key"
|
||||||
VM_DEST="/backups/main-server/"
|
VM_DEST="/backups/main-server/"
|
||||||
|
|
||||||
|
# ── Cloudflare R2 config ─────────────────────────────────────────────────────
|
||||||
|
# Set these via environment or export before running the script
|
||||||
|
R2_ACCOUNT_ID="${R2_ACCOUNT_ID:-35e00c230cc8066252a2d9890b69aea2}"
|
||||||
|
R2_BUCKET="${R2_BUCKET_NAME:-navitrends-backups}"
|
||||||
|
R2_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||||
|
# AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be exported in the env
|
||||||
|
# or set in /root/.r2-credentials (sourced below)
|
||||||
|
|
||||||
|
CREDENTIALS_FILE="/root/.r2-credentials"
|
||||||
|
if [ -f "$CREDENTIALS_FILE" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$CREDENTIALS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
# Log file for backup status (used by boot-check script)
|
# Log file for backup status (used by boot-check script)
|
||||||
BACKUP_LOG_FILE="/root/backups/backup-status.log"
|
BACKUP_LOG_FILE="/root/backups/backup-status.log"
|
||||||
MAX_BACKUPS=10
|
MAX_BACKUPS=10
|
||||||
@@ -34,6 +52,7 @@ log_status() {
|
|||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo "📦 Starting Backup: $BACKUP_NAME"
|
echo "📦 Starting Backup: $BACKUP_NAME"
|
||||||
echo " Apps: Frappe, Nextcloud, Mautic, n8n, Odoo"
|
echo " Apps: Frappe, Nextcloud, Mautic, n8n, Odoo"
|
||||||
|
echo " Tiers: Local → VM → ☁ Cloudflare R2"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
@@ -153,12 +172,15 @@ Hostname: $(hostname)
|
|||||||
Apps: Frappe, Nextcloud, Mautic, n8n, Odoo
|
Apps: Frappe, Nextcloud, Mautic, n8n, Odoo
|
||||||
Volumes: $VOLUME_COUNT volume(s) backed up
|
Volumes: $VOLUME_COUNT volume(s) backed up
|
||||||
Docker info: $(docker --version)
|
Docker info: $(docker --version)
|
||||||
|
Storage Tiers:
|
||||||
|
- Local: /root/backups/
|
||||||
|
- VM: ${VM_HOST}:${VM_PORT} → ${VM_DEST}
|
||||||
|
- Cloud: Cloudflare R2 → s3://${R2_BUCKET}/backups/
|
||||||
|
|
||||||
Volumes included:
|
Volumes included:
|
||||||
$(ls volumes/*.tar.gz 2>/dev/null | xargs -I{} basename {} || echo "none")
|
$(ls volumes/*.tar.gz 2>/dev/null | xargs -I{} basename {} || echo "none")
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Write individual volume checksums for integrity verification later
|
|
||||||
echo "" >> backup-info.txt
|
echo "" >> backup-info.txt
|
||||||
echo "Volume SHA256 checksums:" >> backup-info.txt
|
echo "Volume SHA256 checksums:" >> backup-info.txt
|
||||||
for f in volumes/*.tar.gz; do
|
for f in volumes/*.tar.gz; do
|
||||||
@@ -177,11 +199,9 @@ tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}/"
|
|||||||
COMPRESSED_SIZE=$(du -h "${BACKUP_NAME}.tar.gz" | cut -f1)
|
COMPRESSED_SIZE=$(du -h "${BACKUP_NAME}.tar.gz" | cut -f1)
|
||||||
echo " ✅ Compressed size: $COMPRESSED_SIZE → $BACKUP_ARCHIVE"
|
echo " ✅ Compressed size: $COMPRESSED_SIZE → $BACKUP_ARCHIVE"
|
||||||
|
|
||||||
# Write a top-level SHA256 for the final archive (used by health-check)
|
|
||||||
sha256sum "${BACKUP_NAME}.tar.gz" > "${BACKUP_NAME}.tar.gz.sha256"
|
sha256sum "${BACKUP_NAME}.tar.gz" > "${BACKUP_NAME}.tar.gz.sha256"
|
||||||
echo " ✅ Checksum written: ${BACKUP_NAME}.tar.gz.sha256"
|
echo " ✅ Checksum written: ${BACKUP_NAME}.tar.gz.sha256"
|
||||||
|
|
||||||
# Remove staging directory now that archive is created
|
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
@@ -205,10 +225,10 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# 9. Send to VM over SSH
|
# 9. Send to VM over SSH [TIER 2]
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo "📤 Sending backup to VM (${VM_HOST}:${VM_PORT})..."
|
echo "📤 [TIER 2] Sending backup to VM (${VM_HOST}:${VM_PORT})..."
|
||||||
scp -i "$VM_KEY" \
|
scp -i "$VM_KEY" \
|
||||||
-P "$VM_PORT" \
|
-P "$VM_PORT" \
|
||||||
-o StrictHostKeyChecking=no \
|
-o StrictHostKeyChecking=no \
|
||||||
@@ -218,30 +238,80 @@ scp -i "$VM_KEY" \
|
|||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo " ✅ Backup sent to VM successfully!"
|
echo " ✅ Backup sent to VM successfully!"
|
||||||
# Also send the checksum file
|
|
||||||
scp -i "$VM_KEY" -P "$VM_PORT" -o StrictHostKeyChecking=no \
|
scp -i "$VM_KEY" -P "$VM_PORT" -o StrictHostKeyChecking=no \
|
||||||
"${BACKUP_NAME}.tar.gz.sha256" "${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true
|
"${BACKUP_NAME}.tar.gz.sha256" "${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
echo " ❌ Transfer failed. The compressed backup is still at:"
|
echo " ❌ VM transfer failed. Local backup still at: $BACKUP_ARCHIVE"
|
||||||
echo " $BACKUP_ARCHIVE"
|
|
||||||
echo " 💡 Retry manually:"
|
|
||||||
echo " scp -i $VM_KEY -P $VM_PORT $BACKUP_ARCHIVE ${VM_USER}@${VM_HOST}:${VM_DEST}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# 10. Write final status to log
|
# 10. Upload to Cloudflare R2 [TIER 3 — CLOUD]
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
log_status "SUCCESS" "$BACKUP_NAME" "size=${COMPRESSED_SIZE}"
|
echo ""
|
||||||
|
echo "☁️ [TIER 3] Uploading to Cloudflare R2..."
|
||||||
|
echo " Bucket: ${R2_BUCKET}"
|
||||||
|
echo " Endpoint: ${R2_ENDPOINT}"
|
||||||
|
|
||||||
|
# Check if AWS CLI is available (used for S3-compatible uploads)
|
||||||
|
if command -v aws &>/dev/null && [ -n "${AWS_ACCESS_KEY_ID:-}" ] && [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then
|
||||||
|
# Upload main archive
|
||||||
|
aws s3 cp "${BACKUP_NAME}.tar.gz" \
|
||||||
|
"s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz" \
|
||||||
|
--endpoint-url "${R2_ENDPOINT}" \
|
||||||
|
--no-progress \
|
||||||
|
&& echo " ✅ R2 upload complete: s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz" \
|
||||||
|
|| echo " ❌ R2 upload failed"
|
||||||
|
|
||||||
|
# Upload checksum
|
||||||
|
aws s3 cp "${BACKUP_NAME}.tar.gz.sha256" \
|
||||||
|
"s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz.sha256" \
|
||||||
|
--endpoint-url "${R2_ENDPOINT}" \
|
||||||
|
--no-prth
|
||||||
|
os.environ.setdefault('R2_ACCOUNT_ID', '${R2_ACCOUNT_ID}')
|
||||||
|
os.environ.setdefault('R2_BUCKET_NAME', '${R2_BUCKET}')
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url='${R2_ENDPOINT}',
|
||||||
|
aws_access_key_id=os.environ.get('AWS_ACCESS_KEY_ID',''),
|
||||||
|
aws_secret_access_key=os.environ.get('AWS_SECRET_ACCESS_KEY',''),
|
||||||
|
region_name='auto'
|
||||||
|
)
|
||||||
|
archive = '/root/backups/${BACKUP_NAME}.tar.gz'
|
||||||
|
key = 'backups/${BACKUP_NAME}.tar.gz'
|
||||||
|
size = os.path.getsize(archive)
|
||||||
|
print(f' Uploading {size/1024/1024:.1f} MB...')
|
||||||
|
client.upload_file(archive, '${R2_BUCKET}', key)
|
||||||
|
print(' ✅ R2 upload complete (boto3 fallback)')
|
||||||
|
except Exception as e:
|
||||||
|
print(f' ❌ R2 upload failed: {e}')
|
||||||
|
sys.exit(1)
|
||||||
|
PYEOF
|
||||||
|
R2_STATUS="uploaded via boto3"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Skipping R2 upload: neither aws-cli nor python3/boto3 available"
|
||||||
|
echo " 💡 Install: pip install boto3 OR apt install awscli"
|
||||||
|
echo " 💡 Then set: export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=..."
|
||||||
|
R2_STATUS="skipped (no cli available)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 11. Write final status to log
|
||||||
|
# --------------------------------------------------
|
||||||
|
log_status "SUCCESS" "$BACKUP_NAME" "size=${COMPRESSED_SIZE} | r2=${R2_STATUS}"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo "✅ BACKUP COMPLETE"
|
echo "✅ BACKUP COMPLETE — 3-tier storage"
|
||||||
echo " Name: $BACKUP_NAME"
|
echo ""
|
||||||
echo " Local: $BACKUP_ARCHIVE ($COMPRESSED_SIZE)"
|
echo " 📦 Name: $BACKUP_NAME"
|
||||||
echo " Remote: ${VM_HOST}:${VM_DEST}${BACKUP_NAME}.tar.gz"
|
echo " 💾 Local: $BACKUP_ARCHIVE ($COMPRESSED_SIZE)"
|
||||||
|
echo " 🖥️ VM: ${VM_HOST}:${VM_DEST}${BACKUP_NAME}.tar.gz"
|
||||||
|
echo " ☁️ R2: s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
|
|
||||||
# ── Chiffrement AES-256 ──────────────────────────────────────────────────────
|
# ── AES-256 encryption (optional, call manually) ─────────────────────────────
|
||||||
encrypt_backup() {
|
encrypt_backup() {
|
||||||
echo "🔐 Chiffrement AES-256..."
|
echo "🔐 Chiffrement AES-256..."
|
||||||
openssl enc -aes-256-cbc -pbkdf2 -pass pass:Navitrends2024! \
|
openssl enc -aes-256-cbc -pbkdf2 -pass pass:Navitrends2024! \
|
||||||
@@ -251,9 +321,8 @@ encrypt_backup() {
|
|||||||
echo "✅ Archive chiffrée : ${BACKUP_ARCHIVE}.enc"
|
echo "✅ Archive chiffrée : ${BACKUP_ARCHIVE}.enc"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Notification email échec ─────────────────────────────────────────────────
|
|
||||||
notify_failure() {
|
notify_failure() {
|
||||||
echo "📧 Envoi notification échec..."
|
echo "📧 Envoi notification échec..."
|
||||||
echo "Backup FAILED: $BACKUP_NAME" | \
|
echo "Backup FAILED: $BACKUP_NAME" | \
|
||||||
mail -s "[Navitrends] BACKUP FAILED - $(date)" arijabidi577@gmail.com
|
mail -s "[Navitrends] BACKUP FAILED - $(date)" ameniboukottaya@gmail.com
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user