Sync from main server - 2026-05-13 01:06:32

This commit is contained in:
root
2026-05-13 01:06:32 +02:00
parent 09bbe0403c
commit 6158b34613
8 changed files with 2159 additions and 129 deletions

View File

@@ -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 = []
@@ -141,7 +145,7 @@ def backups_page():
def restore_page(): def restore_page():
prefill = { prefill = {
'source': request.args.get('source', '').strip(), 'source': request.args.get('source', '').strip(),
'file': request.args.get('file', '').strip(), 'file': request.args.get('file', '').strip(),
} }
return render_template( return render_template(
'pages/restore.html', 'pages/restore.html',
@@ -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()
@@ -254,18 +262,19 @@ def api_dashboard():
running = sum(1 for c in all_ctrs if 'Up' in c.get('status', '')) running = sum(1 for c in all_ctrs if 'Up' in c.get('status', ''))
return jsonify({ return jsonify({
'system': system, 'system': system,
'containers': all_ctrs, 'containers': all_ctrs,
'running_count': running, 'running_count': running,
'user_count': len(users), 'user_count': len(users),
'local_backups': len(get_local_backups()), 'local_backups': len(get_local_backups()),
'vm_backups': len(get_vm_backups()), 'vm_backups': len(get_vm_backups()),
}) })
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 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():
@@ -307,7 +316,7 @@ def api_backups():
@app.route('/api/backups/log') @app.route('/api/backups/log')
@login_required @login_required
def api_backup_log(): def api_backup_log():
limit = int(request.args.get('limit', 20)) limit = int(request.args.get('limit', 20))
entries = get_backup_log_entries(limit) entries = get_backup_log_entries(limit)
return jsonify({'entries': entries}) return jsonify({'entries': entries})
@@ -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():
@@ -399,10 +529,10 @@ def api_user_disk(username):
@app.route('/api/users/create', methods=['POST']) @app.route('/api/users/create', methods=['POST'])
@login_required @login_required
def api_create_user(): def api_create_user():
data = request.get_json() or {} data = request.get_json() or {}
username = data.get('username', '').strip() username = data.get('username', '').strip()
password = data.get('password', '').strip() password = data.get('password', '').strip()
setup_docker = data.get('setup_docker', True) setup_docker = data.get('setup_docker', True)
disk_quota_mb = data.get('disk_quota_mb') disk_quota_mb = data.get('disk_quota_mb')
if not username: if not username:
@@ -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):
@@ -496,13 +745,13 @@ def restore_start():
return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500 return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500
if target == 'local': if target == 'local':
hostname = os.uname().nodename hostname = os.uname().nodename
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 = ''

View 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

View File

@@ -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') }}">

View File

@@ -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>
</div>
</div>
<div id="audit-modal" class="modal-overlay" style="display:none;" onclick="closeAuditModal(event)"> {# CLOUD R2 #}
<div class="modal-box" onclick="event.stopPropagation()"> <div>
<div class="modal-header"> <div class="section-header">
<div class="modal-title"><i class="fas fa-shield-halved" style="color:var(--cyan);"></i> Backup Health Audit</div> <div class="section-title">☁️ CLOUD (R2)</div>
<button class="modal-close" onclick="closeAuditModal()" title="Close"></button> <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 id="audit-modal-content"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div></div>
</div> </div>
</div> </div>
{# ── Backup History ── #}
<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 %}

View 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 &amp; 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 &gt; /root/.r2-credentials &lt;&lt; '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 %}

View File

@@ -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 %}

View File

@@ -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 12 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 %}

View File

@@ -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
} }