Sync from main server - 2026-04-15 13:03:38
This commit is contained in:
209
platform/app.py
209
platform/app.py
@@ -4,10 +4,17 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
from config import MAIN_SERVER_IP, VM_HOST, VM_PORT, VM_KEY, VM_USER, RUNNING_ON_MAIN_SERVER
|
from config import MAIN_SERVER_IP, VM_HOST, VM_PORT, VM_KEY, VM_USER
|
||||||
from modules.auth import login_required
|
from modules.auth import login_required
|
||||||
from modules.backups import get_containers, get_local_backups, get_vm_backups
|
from modules.backups import (
|
||||||
|
get_containers, get_all_root_containers, get_local_backups,
|
||||||
|
get_vm_backups, get_all_stats, get_system_info
|
||||||
|
)
|
||||||
from modules.commands import run_command, run_ssh_to_vm
|
from modules.commands import run_command, run_ssh_to_vm
|
||||||
|
from modules.users import (
|
||||||
|
get_all_users, get_user_containers, get_all_users_containers,
|
||||||
|
create_user, delete_user, get_user_disk_usage
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = 'navitrends-secret-key-2025'
|
app.secret_key = 'navitrends-secret-key-2025'
|
||||||
@@ -33,6 +40,9 @@ def _stream_restore(job_id, cmd):
|
|||||||
restore_jobs[job_id]['status'] = 'error'
|
restore_jobs[job_id]['status'] = 'error'
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# DASHBOARD
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@@ -40,14 +50,121 @@ def dashboard():
|
|||||||
running_count = sum(1 for c in containers if 'Up' in c.get('status', ''))
|
running_count = sum(1 for c in containers if 'Up' in c.get('status', ''))
|
||||||
backups = get_local_backups()
|
backups = get_local_backups()
|
||||||
vm_backups = get_vm_backups()
|
vm_backups = get_vm_backups()
|
||||||
|
system = get_system_info()
|
||||||
|
users = get_all_users()
|
||||||
return render_template('dashboard.html',
|
return render_template('dashboard.html',
|
||||||
containers=containers,
|
containers=containers,
|
||||||
running_count=running_count,
|
running_count=running_count,
|
||||||
backups=backups,
|
backups=backups,
|
||||||
vm_backups=vm_backups,
|
vm_backups=vm_backups,
|
||||||
main_server=MAIN_SERVER_IP)
|
main_server=MAIN_SERVER_IP,
|
||||||
|
system=system,
|
||||||
|
users=users)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# API — system info + stats (live poll)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
@app.route('/api/system')
|
||||||
|
@login_required
|
||||||
|
def api_system():
|
||||||
|
return jsonify(get_system_info())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/stats')
|
||||||
|
@login_required
|
||||||
|
def api_stats():
|
||||||
|
"""Container resource stats for ALL containers (root + rootless users)."""
|
||||||
|
return jsonify(get_all_stats())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/containers')
|
||||||
|
@login_required
|
||||||
|
def api_containers():
|
||||||
|
containers = get_all_root_containers()
|
||||||
|
running_count = sum(1 for c in containers if 'Up' in c.get('status', ''))
|
||||||
|
return jsonify({'containers': containers, 'running': running_count})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/containers/all')
|
||||||
|
@login_required
|
||||||
|
def api_containers_all():
|
||||||
|
"""Root containers + all users' rootless containers combined."""
|
||||||
|
root_ctrs = get_all_root_containers()
|
||||||
|
user_ctrs = get_all_users_containers()
|
||||||
|
all_ctrs = root_ctrs + user_ctrs
|
||||||
|
running = sum(1 for c in all_ctrs if 'Up' in c.get('status', ''))
|
||||||
|
return jsonify({'containers': all_ctrs, 'running': running})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# API — backups
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
@app.route('/api/backups')
|
||||||
|
@login_required
|
||||||
|
def api_backups():
|
||||||
|
return jsonify({'local': get_local_backups(), 'vm': get_vm_backups()})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# API — users management
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
@app.route('/api/users')
|
||||||
|
@login_required
|
||||||
|
def api_users():
|
||||||
|
return jsonify(get_all_users())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users/<username>/containers')
|
||||||
|
@login_required
|
||||||
|
def api_user_containers(username):
|
||||||
|
return jsonify(get_user_containers(username))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users/<username>/disk')
|
||||||
|
@login_required
|
||||||
|
def api_user_disk(username):
|
||||||
|
return jsonify(get_user_disk_usage(username))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users/create', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_create_user():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '').strip()
|
||||||
|
setup_docker = data.get('setup_docker', True)
|
||||||
|
disk_quota_mb = data.get('disk_quota_mb')
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return jsonify({'success': False, 'message': 'Username required'}), 400
|
||||||
|
|
||||||
|
success, message = create_user(
|
||||||
|
username=username,
|
||||||
|
password=password or None,
|
||||||
|
setup_docker=setup_docker,
|
||||||
|
disk_quota_mb=disk_quota_mb,
|
||||||
|
)
|
||||||
|
return jsonify({'success': success, 'message': message})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/users/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_delete_user():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
remove_home = data.get('remove_home', False)
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return jsonify({'success': False, 'message': 'Username required'}), 400
|
||||||
|
|
||||||
|
success, message = delete_user(username, remove_home=remove_home)
|
||||||
|
return jsonify({'success': success, 'message': message})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# RESTORE
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
@app.route('/restore/start', methods=['POST'])
|
@app.route('/restore/start', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def restore_start():
|
def restore_start():
|
||||||
@@ -68,41 +185,22 @@ 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
|
||||||
|
|
||||||
# ── Resolve backup archive path on THIS server ───────────────────────────
|
# Resolve archive path on this server
|
||||||
if backup_source == 'local':
|
if backup_source == 'local':
|
||||||
# Local path depends on which server we're running on
|
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
|
||||||
backup_path = f"/root/backups/{backup_file}"
|
backup_path = f"/root/backups/{backup_file}"
|
||||||
else:
|
|
||||||
backup_path = f"/backups/main-server/{backup_file}"
|
|
||||||
|
|
||||||
if not os.path.exists(backup_path):
|
if not os.path.exists(backup_path):
|
||||||
return jsonify({'error': f'Backup not found: {backup_path}'}), 400
|
return jsonify({'error': f'Backup not found: {backup_path}'}), 400
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# "Other server" backup source — pull it to /tmp/ first
|
|
||||||
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):
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
|
||||||
# Original logic: pull from VM via SSH tunnel (port 2223)
|
|
||||||
pull_cmd = (
|
pull_cmd = (
|
||||||
f"scp -i {VM_KEY} -P {VM_PORT} "
|
f"scp -i {VM_KEY} -P {VM_PORT} "
|
||||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
||||||
f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} "
|
f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} {backup_path}"
|
||||||
f"{backup_path}"
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# On VM: pull from main server's /root/backups/ via port 22
|
|
||||||
pull_cmd = (
|
|
||||||
f"scp -i {VM_KEY} -P 22 "
|
|
||||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
|
||||||
f"{VM_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} "
|
|
||||||
f"{backup_path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
result = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return jsonify({'error': f'Failed to pull backup: {result.stderr}'}), 500
|
return jsonify({'error': f'Failed to pull from VM: {result.stderr}'}), 500
|
||||||
|
|
||||||
restore_script_local = os.path.join(
|
restore_script_local = os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)), 'restore-myapps.sh'
|
os.path.dirname(os.path.abspath(__file__)), 'restore-myapps.sh'
|
||||||
@@ -110,60 +208,48 @@ def restore_start():
|
|||||||
if not os.path.exists(restore_script_local):
|
if not os.path.exists(restore_script_local):
|
||||||
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
|
||||||
|
|
||||||
# ── Build command ────────────────────────────────────────────────────────
|
|
||||||
if target == 'local':
|
if target == 'local':
|
||||||
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 && mkdir -p {session_dir} && "
|
||||||
f"mkdir -p {session_dir} && "
|
|
||||||
f"echo '📂 Extracting backup locally...' && "
|
f"echo '📂 Extracting backup locally...' && "
|
||||||
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 && "
|
||||||
f"cd {session_dir} && "
|
f"cd {session_dir} && bash restore-myapps.sh ; "
|
||||||
f"bash restore-myapps.sh ; "
|
|
||||||
f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT"
|
f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT"
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if not remote_ip:
|
if not remote_ip:
|
||||||
return jsonify({'error': 'remote_ip is required for remote restore'}), 400
|
return jsonify({'error': 'remote_ip required for remote restore'}), 400
|
||||||
|
|
||||||
base_ssh_opts = f"-o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
|
||||||
|
|
||||||
|
base_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||||
if auth_method == 'key':
|
if auth_method == 'key':
|
||||||
if not ssh_key_path:
|
if not ssh_key_path:
|
||||||
return jsonify({'error': 'ssh_key_path is required'}), 400
|
return jsonify({'error': 'ssh_key_path required'}), 400
|
||||||
ssh_prefix = f"ssh -p {remote_port} -i {ssh_key_path} {base_ssh_opts}"
|
ssh_prefix = f"ssh -p {remote_port} -i {ssh_key_path} {base_opts}"
|
||||||
scp_prefix = f"scp -P {remote_port} -i {ssh_key_path} {base_ssh_opts}"
|
scp_prefix = f"scp -P {remote_port} -i {ssh_key_path} {base_opts}"
|
||||||
else:
|
else:
|
||||||
if not ssh_password:
|
if not ssh_password:
|
||||||
return jsonify({'error': 'ssh_password is required'}), 400
|
return jsonify({'error': 'ssh_password required'}), 400
|
||||||
ssh_prefix = f"sshpass -p '{ssh_password}' ssh -p {remote_port} {base_ssh_opts}"
|
ssh_prefix = f"sshpass -p '{ssh_password}' ssh -p {remote_port} {base_opts}"
|
||||||
scp_prefix = f"sshpass -p '{ssh_password}' scp -P {remote_port} {base_ssh_opts}"
|
scp_prefix = f"sshpass -p '{ssh_password}' scp -P {remote_port} {base_opts}"
|
||||||
|
|
||||||
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 to {remote_ip}:{remote_port}...' && "
|
|
||||||
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 && "
|
f"'set -e && cd {remote_dest} && "
|
||||||
f"cd {remote_dest} && "
|
|
||||||
f"echo \"📂 Extracting backup...\" && "
|
f"echo \"📂 Extracting backup...\" && "
|
||||||
f"tar -xzf {backup_file} --strip-components=1 && "
|
f"tar -xzf {backup_file} --strip-components=1 && "
|
||||||
f"chmod +x restore-myapps.sh && "
|
f"chmod +x restore-myapps.sh && bash restore-myapps.sh' ; "
|
||||||
f"bash restore-myapps.sh' ; "
|
|
||||||
|
|
||||||
f"EXIT=$? ; "
|
f"EXIT=$? ; "
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; "
|
f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; "
|
||||||
f"exit $EXIT"
|
f"exit $EXIT"
|
||||||
@@ -172,7 +258,6 @@ def restore_start():
|
|||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
t = threading.Thread(target=_stream_restore, args=(job_id, cmd), daemon=True)
|
t = threading.Thread(target=_stream_restore, args=(job_id, cmd), daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
return jsonify({'job_id': job_id, 'status': 'started'})
|
return jsonify({'job_id': job_id, 'status': 'started'})
|
||||||
|
|
||||||
|
|
||||||
@@ -189,22 +274,9 @@ def restore_status_poll(job_id):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/backups')
|
# ─────────────────────────────────────────────
|
||||||
@login_required
|
# SERVER STATUS
|
||||||
def api_backups():
|
# ─────────────────────────────────────────────
|
||||||
return jsonify({'local': get_local_backups(), 'vm': get_vm_backups()})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/containers')
|
|
||||||
@login_required
|
|
||||||
def api_containers():
|
|
||||||
containers = get_containers()
|
|
||||||
return jsonify({
|
|
||||||
'containers': containers,
|
|
||||||
'running': sum(1 for c in containers if 'Up' in c.get('status', ''))
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/server/status')
|
@app.route('/server/status')
|
||||||
@login_required
|
@login_required
|
||||||
def server_status():
|
def server_status():
|
||||||
@@ -214,6 +286,9 @@ def server_status():
|
|||||||
return jsonify({'status': 'online', 'info': stdout.strip()})
|
return jsonify({'status': 'online', 'info': stdout.strip()})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# AUTH
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
error = ''
|
error = ''
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
import subprocess
|
import subprocess
|
||||||
from config import RUNNING_ON_MAIN_SERVER, VM_HOST, VM_PORT, VM_KEY, VM_USER, MAIN_SERVER_IP
|
import json
|
||||||
|
from config import RUNNING_ON_MAIN_SERVER, VM_HOST, VM_PORT, VM_KEY, VM_USER
|
||||||
|
|
||||||
|
|
||||||
def run_command(cmd):
|
def _run(cmd, timeout=20):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||||
return result.stdout, result.stderr
|
return r.stdout.strip(), r.stderr.strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return '', str(e)
|
return '', str(e)
|
||||||
|
|
||||||
|
|
||||||
def get_local_backups():
|
# ────────────────────────────────────────────────────────────────
|
||||||
"""
|
# BACKUPS
|
||||||
On main server → /root/backups/ (backups of the main server, stored locally)
|
# ────────────────────────────────────────────────────────────────
|
||||||
On VM → /backups/main-server/ (backups of the main server, stored on VM)
|
|
||||||
"""
|
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
|
||||||
path = "/root/backups/myapps-backup-*.tar.gz"
|
|
||||||
else:
|
|
||||||
path = "/backups/main-server/myapps-backup-*.tar.gz"
|
|
||||||
|
|
||||||
stdout, _ = run_command(f"ls -t {path} 2>/dev/null | head -20")
|
def get_local_backups():
|
||||||
|
stdout, _ = _run("ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null | head -20")
|
||||||
files = []
|
files = []
|
||||||
if stdout:
|
if stdout:
|
||||||
for line in stdout.strip().split('\n'):
|
for line in stdout.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
if line:
|
||||||
files.append(os.path.basename(line))
|
files.append(os.path.basename(line))
|
||||||
@@ -33,14 +29,8 @@ def get_local_backups():
|
|||||||
|
|
||||||
|
|
||||||
def get_vm_backups():
|
def get_vm_backups():
|
||||||
"""
|
vm_backups = []
|
||||||
On main server → SSH into VM (via tunnel port 2223) to list /backups/main-server/
|
|
||||||
On VM → SSH into main server (port 22) to list /root/backups/
|
|
||||||
"""
|
|
||||||
backups = []
|
|
||||||
|
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
# Original logic — unchanged
|
|
||||||
try:
|
try:
|
||||||
cmd = (
|
cmd = (
|
||||||
f"ssh -i {VM_KEY} -p {VM_PORT} "
|
f"ssh -i {VM_KEY} -p {VM_PORT} "
|
||||||
@@ -48,58 +38,143 @@ def get_vm_backups():
|
|||||||
f"{VM_USER}@{VM_HOST} "
|
f"{VM_USER}@{VM_HOST} "
|
||||||
f"'ls -t /backups/main-server/myapps-backup-*.tar.gz 2>/dev/null | head -20'"
|
f"'ls -t /backups/main-server/myapps-backup-*.tar.gz 2>/dev/null | head -20'"
|
||||||
)
|
)
|
||||||
stdout, _ = run_command(cmd)
|
stdout, _ = _run(cmd, timeout=20)
|
||||||
if stdout:
|
if stdout:
|
||||||
for line in stdout.strip().split('\n'):
|
for line in stdout.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line and '.tar.gz' in line:
|
if line and '.tar.gz' in line:
|
||||||
backups.append(os.path.basename(line))
|
vm_backups.append(os.path.basename(line))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[backups] Error fetching VM backups: {e}")
|
print(f"[backups] VM backup fetch error: {e}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Running on VM → SSH into main server to list its local backups
|
backup_dir = '/backups/main-server'
|
||||||
try:
|
if os.path.exists(backup_dir):
|
||||||
cmd = (
|
files = glob.glob(f'{backup_dir}/myapps-backup-*.tar.gz')
|
||||||
f"ssh -i {VM_KEY} -p 22 "
|
files.sort(key=os.path.getmtime, reverse=True)
|
||||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
|
vm_backups = [os.path.basename(f) for f in files[:20]]
|
||||||
f"{VM_USER}@{MAIN_SERVER_IP} "
|
return vm_backups
|
||||||
f"'ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null | head -20'"
|
|
||||||
)
|
|
||||||
stdout, _ = run_command(cmd)
|
|
||||||
if stdout:
|
|
||||||
for line in stdout.strip().split('\n'):
|
|
||||||
line = line.strip()
|
|
||||||
if line and '.tar.gz' in line:
|
|
||||||
backups.append(os.path.basename(line))
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[backups] Error fetching main server backups from VM: {e}")
|
|
||||||
|
|
||||||
return backups
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# ROOT CONTAINERS
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_containers():
|
def get_containers():
|
||||||
"""
|
"""Root app containers only (filtered)."""
|
||||||
On main server → local docker ps (original, unchanged)
|
stdout, _ = _run(
|
||||||
On VM → SSH into main server to get its containers
|
"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null | "
|
||||||
"""
|
"grep -E 'frappe|nextcloud|mautic|n8n|odoo'"
|
||||||
if RUNNING_ON_MAIN_SERVER:
|
)
|
||||||
stdout, _ = run_command(
|
containers = []
|
||||||
"docker ps -a --format '{{.Names}}|{{.Status}}' 2>/dev/null"
|
if stdout:
|
||||||
|
for line in stdout.split('\n'):
|
||||||
|
if '|' not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split('|')
|
||||||
|
containers.append({
|
||||||
|
'name': parts[0].strip(),
|
||||||
|
'status': parts[1].strip(),
|
||||||
|
'image': parts[2].strip(),
|
||||||
|
'ports': parts[3].strip() if len(parts) > 3 else '',
|
||||||
|
'owner': 'root',
|
||||||
|
})
|
||||||
|
return containers
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_root_containers():
|
||||||
|
"""ALL root docker containers (unfiltered)."""
|
||||||
|
stdout, _ = _run(
|
||||||
|
"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null"
|
||||||
|
)
|
||||||
|
containers = []
|
||||||
|
if stdout:
|
||||||
|
for line in stdout.split('\n'):
|
||||||
|
if '|' not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split('|')
|
||||||
|
containers.append({
|
||||||
|
'name': parts[0].strip(),
|
||||||
|
'status': parts[1].strip(),
|
||||||
|
'image': parts[2].strip(),
|
||||||
|
'ports': parts[3].strip() if len(parts) > 3 else '',
|
||||||
|
'owner': 'root',
|
||||||
|
})
|
||||||
|
return containers
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# CONTAINER STATS
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_container_stats(docker_socket=None):
|
||||||
|
"""One-shot stats snapshot. Returns dict keyed by container name."""
|
||||||
|
if docker_socket:
|
||||||
|
cmd = (
|
||||||
|
f"DOCKER_HOST=unix://{docker_socket} "
|
||||||
|
f"docker stats --no-stream --format "
|
||||||
|
f"'{{{{.Name}}}}|{{{{.CPUPerc}}}}|{{{{.MemUsage}}}}|{{{{.MemPerc}}}}|{{{{.NetIO}}}}|{{{{.BlockIO}}}}' 2>/dev/null"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cmd = (
|
cmd = (
|
||||||
f"ssh -i {VM_KEY} -p 22 "
|
"docker stats --no-stream --format "
|
||||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
|
"'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null"
|
||||||
f"{VM_USER}@{MAIN_SERVER_IP} "
|
|
||||||
f"\"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}' 2>/dev/null\""
|
|
||||||
)
|
)
|
||||||
stdout, _ = run_command(cmd)
|
|
||||||
|
|
||||||
containers = []
|
stdout, _ = _run(cmd, timeout=30)
|
||||||
|
stats = {}
|
||||||
if stdout:
|
if stdout:
|
||||||
for line in stdout.strip().split('\n'):
|
for line in stdout.split('\n'):
|
||||||
if '|' in line:
|
if '|' not in line:
|
||||||
name, status = line.split('|', 1)
|
continue
|
||||||
containers.append({'name': name.strip(), 'status': status.strip()})
|
parts = line.split('|')
|
||||||
return containers
|
if len(parts) < 6:
|
||||||
|
continue
|
||||||
|
name = parts[0].strip()
|
||||||
|
stats[name] = {
|
||||||
|
'cpu': parts[1].strip(),
|
||||||
|
'mem': parts[2].strip(),
|
||||||
|
'mem_pct': parts[3].strip(),
|
||||||
|
'net': parts[4].strip(),
|
||||||
|
'block': parts[5].strip(),
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_stats():
|
||||||
|
"""Stats for root + all rootless-user containers combined."""
|
||||||
|
all_stats = get_container_stats()
|
||||||
|
try:
|
||||||
|
import pwd
|
||||||
|
for pw in pwd.getpwall():
|
||||||
|
if pw.pw_uid < 1000 or pw.pw_name == 'nobody':
|
||||||
|
continue
|
||||||
|
sock = f"/run/user/{pw.pw_uid}/docker.sock"
|
||||||
|
if os.path.exists(sock):
|
||||||
|
user_stats = get_container_stats(docker_socket=sock)
|
||||||
|
all_stats.update(user_stats)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[stats] Error: {e}")
|
||||||
|
return all_stats
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_info():
|
||||||
|
"""Host-level system stats."""
|
||||||
|
cpu_out, _ = _run("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
|
||||||
|
mem_out, _ = _run("free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}'")
|
||||||
|
mem_pct, _ = _run("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'")
|
||||||
|
disk_out, _ = _run("df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}'")
|
||||||
|
disk_pct, _ = _run("df / | awk 'NR==2{print $5}' | tr -d '%'")
|
||||||
|
load_out, _ = _run("cat /proc/loadavg | awk '{print $1, $2, $3}'")
|
||||||
|
uptime_out, _ = _run("uptime -p")
|
||||||
|
docker_v, _ = _run("docker --version | cut -d' ' -f3 | tr -d ','")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'cpu_pct': cpu_out or '0',
|
||||||
|
'memory': mem_out or 'N/A',
|
||||||
|
'mem_pct': mem_pct or '0',
|
||||||
|
'disk': disk_out or 'N/A',
|
||||||
|
'disk_pct': disk_pct or '0',
|
||||||
|
'load': load_out or 'N/A',
|
||||||
|
'uptime': uptime_out or 'N/A',
|
||||||
|
'docker_v': docker_v or 'N/A',
|
||||||
|
}
|
||||||
|
|||||||
455
platform/modules/users.py
Normal file
455
platform/modules/users.py
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import stat
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd, timeout=20):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||||
|
return r.stdout.strip(), r.stderr.strip()
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return '', 'timeout'
|
||||||
|
except Exception as e:
|
||||||
|
return '', str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users():
|
||||||
|
"""Return list of non-system users (uid >= 1000) with info."""
|
||||||
|
users = []
|
||||||
|
try:
|
||||||
|
for pw in pwd.getpwall():
|
||||||
|
if pw.pw_uid < 1000 or pw.pw_name == 'nobody':
|
||||||
|
continue
|
||||||
|
uid = pw.pw_uid
|
||||||
|
name = pw.pw_name
|
||||||
|
home = pw.pw_dir
|
||||||
|
|
||||||
|
sock = f"/run/user/{uid}/docker.sock"
|
||||||
|
has_docker = os.path.exists(sock)
|
||||||
|
|
||||||
|
disk_out, _ = _run(f"du -sh {home} 2>/dev/null | cut -f1")
|
||||||
|
disk_used = disk_out.strip() or 'N/A'
|
||||||
|
|
||||||
|
linger_out, _ = _run(f"loginctl show-user {name} --property=Linger 2>/dev/null")
|
||||||
|
linger = 'yes' in linger_out.lower()
|
||||||
|
|
||||||
|
container_count = 0
|
||||||
|
if has_docker:
|
||||||
|
cnt_out, _ = _run(
|
||||||
|
f"DOCKER_HOST=unix://{sock} docker ps -aq 2>/dev/null | wc -l"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
container_count = int(cnt_out.strip())
|
||||||
|
except ValueError:
|
||||||
|
container_count = 0
|
||||||
|
|
||||||
|
# Check if user has a dedicated virtual disk mounted
|
||||||
|
disk_img = f"/home/{name}.img"
|
||||||
|
has_vdisk = os.path.exists(disk_img)
|
||||||
|
vdisk_mount = None
|
||||||
|
vdisk_size = None
|
||||||
|
if has_vdisk:
|
||||||
|
# Check if it's mounted somewhere
|
||||||
|
mnt_out, _ = _run(f"findmnt -S {disk_img} -o TARGET --noheadings 2>/dev/null")
|
||||||
|
vdisk_mount = mnt_out.strip() or None
|
||||||
|
# Get size of the image
|
||||||
|
size_out, _ = _run(f"du -sh {disk_img} 2>/dev/null | cut -f1")
|
||||||
|
vdisk_size = size_out.strip() or None
|
||||||
|
|
||||||
|
users.append({
|
||||||
|
'name': name,
|
||||||
|
'uid': uid,
|
||||||
|
'home': home,
|
||||||
|
'has_docker': has_docker,
|
||||||
|
'docker_socket': sock if has_docker else None,
|
||||||
|
'disk_used': disk_used,
|
||||||
|
'linger': linger,
|
||||||
|
'container_count': container_count,
|
||||||
|
'has_vdisk': has_vdisk,
|
||||||
|
'vdisk_mount': vdisk_mount,
|
||||||
|
'vdisk_size': vdisk_size,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[users] Error listing users: {e}")
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_containers(username):
|
||||||
|
"""Get containers running under a specific user's rootless docker."""
|
||||||
|
try:
|
||||||
|
pw = pwd.getpwnam(username)
|
||||||
|
except KeyError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
uid = pw.pw_uid
|
||||||
|
sock = f"/run/user/{uid}/docker.sock"
|
||||||
|
if not os.path.exists(sock):
|
||||||
|
return []
|
||||||
|
|
||||||
|
out, _ = _run(
|
||||||
|
f"DOCKER_HOST=unix://{sock} "
|
||||||
|
f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null"
|
||||||
|
)
|
||||||
|
containers = []
|
||||||
|
if out:
|
||||||
|
for line in out.split('\n'):
|
||||||
|
if '|' not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split('|')
|
||||||
|
containers.append({
|
||||||
|
'name': parts[0] if len(parts) > 0 else '',
|
||||||
|
'status': parts[1] if len(parts) > 1 else '',
|
||||||
|
'image': parts[2] if len(parts) > 2 else '',
|
||||||
|
'ports': parts[3] if len(parts) > 3 else '',
|
||||||
|
'owner': username,
|
||||||
|
})
|
||||||
|
return containers
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users_containers():
|
||||||
|
"""Get containers from ALL users' rootless docker instances."""
|
||||||
|
all_containers = []
|
||||||
|
for user in get_all_users():
|
||||||
|
if user['has_docker']:
|
||||||
|
all_containers.extend(get_user_containers(user['name']))
|
||||||
|
return all_containers
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(username, password=None, setup_docker=True, disk_quota_mb=None):
|
||||||
|
"""
|
||||||
|
Create a new system user and optionally set up rootless docker + virtual disk.
|
||||||
|
disk_quota_mb: if set, creates a loop-device virtual disk of that size (MB)
|
||||||
|
and mounts it as the user's home directory.
|
||||||
|
Returns (success: bool, log_text: str)
|
||||||
|
"""
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if not re.match(r'^[a-z][a-z0-9_-]{1,30}$', username):
|
||||||
|
return False, "Invalid username. Use lowercase letters, numbers, _ or -"
|
||||||
|
|
||||||
|
# Check existence
|
||||||
|
try:
|
||||||
|
pwd.getpwnam(username)
|
||||||
|
return False, f"User '{username}' already exists"
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
out, err = _run(f"useradd -m -s /bin/bash {username}")
|
||||||
|
if err and 'already exists' not in err:
|
||||||
|
return False, f"useradd failed: {err}"
|
||||||
|
logs.append(f"✅ User {username} created")
|
||||||
|
|
||||||
|
# Set password
|
||||||
|
if password:
|
||||||
|
out, err = _run(f"echo '{username}:{password}' | chpasswd")
|
||||||
|
if err:
|
||||||
|
logs.append(f"⚠️ Password set failed: {err}")
|
||||||
|
else:
|
||||||
|
logs.append("✅ Password set")
|
||||||
|
|
||||||
|
# Install prerequisites
|
||||||
|
_run("apt-get install -y uidmap dbus-user-session curl 2>/dev/null", timeout=60)
|
||||||
|
logs.append("✅ Prerequisites ready")
|
||||||
|
|
||||||
|
# Enable linger
|
||||||
|
_run(f"loginctl enable-linger {username}")
|
||||||
|
logs.append("✅ Linger enabled")
|
||||||
|
|
||||||
|
# Virtual disk (loop device) instead of quota
|
||||||
|
if disk_quota_mb:
|
||||||
|
success, msg = _setup_virtual_disk(username, disk_quota_mb, logs)
|
||||||
|
if not success:
|
||||||
|
logs.append(f"⚠️ Virtual disk setup failed: {msg}")
|
||||||
|
|
||||||
|
# Setup rootless docker
|
||||||
|
if setup_docker:
|
||||||
|
success, msg = _setup_rootless_docker_via_script(username, logs)
|
||||||
|
if not success:
|
||||||
|
logs.append(f"⚠️ Docker setup incomplete: {msg}")
|
||||||
|
|
||||||
|
return True, '\n'.join(logs)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_virtual_disk(username, disk_mb, logs):
|
||||||
|
"""
|
||||||
|
Create a loop-device virtual disk for a user and mount it as their home.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Create a blank image file at /home/<username>.img
|
||||||
|
2. Format it as ext4
|
||||||
|
3. Copy existing home contents into it
|
||||||
|
4. Mount it over /home/<username>
|
||||||
|
5. Add to /etc/fstab for persistence across reboots
|
||||||
|
6. Fix ownership
|
||||||
|
|
||||||
|
Returns (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pw = pwd.getpwnam(username)
|
||||||
|
except KeyError as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
home = pw.pw_dir # e.g. /home/secuser4
|
||||||
|
img_path = f"/home/{username}.img"
|
||||||
|
|
||||||
|
logs.append(f"📦 Creating {disk_mb} MB virtual disk at {img_path} ...")
|
||||||
|
|
||||||
|
# ── Step 1: Create the blank image ──────────────────────────────────────
|
||||||
|
# Use fallocate (fast, instant) with dd fallback
|
||||||
|
out, err = _run(f"fallocate -l {disk_mb}M {img_path}", timeout=60)
|
||||||
|
if err and 'fallocate' in err:
|
||||||
|
logs.append(" ↳ fallocate not available, using dd (this may take a moment)...")
|
||||||
|
out, err = _run(
|
||||||
|
f"dd if=/dev/zero of={img_path} bs=1M count={disk_mb} status=none",
|
||||||
|
timeout=600
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return False, f"Failed to create image: {err}"
|
||||||
|
logs.append(f" ✅ Image file created ({disk_mb} MB)")
|
||||||
|
|
||||||
|
# ── Step 2: Format as ext4 ───────────────────────────────────────────────
|
||||||
|
out, err = _run(f"mkfs.ext4 -F {img_path}", timeout=60)
|
||||||
|
if err and 'mke2fs' not in err and 'Discarding device blocks' not in err:
|
||||||
|
# mkfs.ext4 writes info to stderr even on success; only fail on real errors
|
||||||
|
if 'error' in err.lower() or 'failed' in err.lower():
|
||||||
|
return False, f"mkfs.ext4 failed: {err}"
|
||||||
|
logs.append(" ✅ Formatted as ext4")
|
||||||
|
|
||||||
|
# ── Step 3: Back up current home contents ────────────────────────────────
|
||||||
|
tmp_backup = f"/tmp/{username}_home_backup"
|
||||||
|
_run(f"cp -a {home}/. {tmp_backup}/ 2>/dev/null")
|
||||||
|
|
||||||
|
# ── Step 4: Mount the image over the user's home ─────────────────────────
|
||||||
|
out, err = _run(f"mount -o loop {img_path} {home}")
|
||||||
|
if err:
|
||||||
|
return False, f"mount failed: {err}"
|
||||||
|
logs.append(f" ✅ Mounted at {home}")
|
||||||
|
|
||||||
|
# ── Step 5: Restore home contents into the new disk ──────────────────────
|
||||||
|
_run(f"cp -a {tmp_backup}/. {home}/ 2>/dev/null")
|
||||||
|
_run(f"rm -rf {tmp_backup}")
|
||||||
|
|
||||||
|
# ── Step 6: Fix ownership ─────────────────────────────────────────────────
|
||||||
|
_run(f"chown -R {username}:{username} {home}")
|
||||||
|
logs.append(" ✅ Ownership set")
|
||||||
|
|
||||||
|
# ── Step 7: Add to /etc/fstab for persistence ────────────────────────────
|
||||||
|
fstab_line = f"{img_path} {home} ext4 loop,defaults 0 0\n"
|
||||||
|
try:
|
||||||
|
with open('/etc/fstab', 'r') as f:
|
||||||
|
fstab = f.read()
|
||||||
|
if img_path not in fstab:
|
||||||
|
with open('/etc/fstab', 'a') as f:
|
||||||
|
f.write(fstab_line)
|
||||||
|
logs.append(" ✅ Added to /etc/fstab (persistent across reboots)")
|
||||||
|
else:
|
||||||
|
logs.append(" ℹ️ Already in /etc/fstab")
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f" ⚠️ Could not update /etc/fstab: {e}")
|
||||||
|
|
||||||
|
logs.append(f"✅ Virtual disk ready: {disk_mb} MB dedicated to {username}")
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_virtual_disk(username, logs):
|
||||||
|
"""Unmount and remove the virtual disk image for a user."""
|
||||||
|
try:
|
||||||
|
pw = pwd.getpwnam(username)
|
||||||
|
home = pw.pw_dir
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
|
||||||
|
img_path = f"/home/{username}.img"
|
||||||
|
|
||||||
|
# Unmount
|
||||||
|
_run(f"umount {home} 2>/dev/null")
|
||||||
|
logs.append(f" ↳ Unmounted {home}")
|
||||||
|
|
||||||
|
# Remove image
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
os.remove(img_path)
|
||||||
|
logs.append(f" ↳ Removed {img_path}")
|
||||||
|
|
||||||
|
# Remove from fstab
|
||||||
|
try:
|
||||||
|
with open('/etc/fstab', 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
with open('/etc/fstab', 'w') as f:
|
||||||
|
for line in lines:
|
||||||
|
if img_path not in line:
|
||||||
|
f.write(line)
|
||||||
|
logs.append(" ↳ Removed from /etc/fstab")
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f" ⚠️ fstab cleanup error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_rootless_docker_via_script(username, logs):
|
||||||
|
"""
|
||||||
|
Setup rootless Docker for a user by running the official installer.
|
||||||
|
This must be done AS the user in a proper login shell.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pw = pwd.getpwnam(username)
|
||||||
|
uid = pw.pw_uid
|
||||||
|
home = pw.pw_dir
|
||||||
|
except KeyError as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
# First, ensure the sysctl setting is applied (critical!)
|
||||||
|
_run("sysctl -w kernel.apparmor_restrict_unprivileged_userns=0")
|
||||||
|
_run("echo 'kernel.apparmor_restrict_unprivileged_userns=0' >> /etc/sysctl.conf")
|
||||||
|
|
||||||
|
# Ensure XDG_RUNTIME_DIR exists with correct permissions
|
||||||
|
runtime_dir = f"/run/user/{uid}"
|
||||||
|
os.makedirs(runtime_dir, exist_ok=True)
|
||||||
|
_run(f"chown {username}:{username} {runtime_dir}")
|
||||||
|
_run(f"chmod 700 {runtime_dir}")
|
||||||
|
|
||||||
|
logs.append(f"📝 Installing rootless Docker for {username}...")
|
||||||
|
|
||||||
|
# Create a simple installation script that runs as the user
|
||||||
|
install_cmd = f"""bash -c '
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/{uid}
|
||||||
|
export PATH=$HOME/bin:$PATH
|
||||||
|
mkdir -p $XDG_RUNTIME_DIR
|
||||||
|
chmod 700 $XDG_RUNTIME_DIR
|
||||||
|
|
||||||
|
# Install rootless Docker
|
||||||
|
curl -fsSL https://get.docker.com/rootless | sh
|
||||||
|
|
||||||
|
# Add environment variables to .bashrc
|
||||||
|
echo "export PATH=$HOME/bin:\\$PATH" >> ~/.bashrc
|
||||||
|
echo "export DOCKER_HOST=unix:///run/user/{uid}/docker.sock" >> ~/.bashrc
|
||||||
|
echo "export XDG_RUNTIME_DIR=/run/user/{uid}" >> ~/.bashrc
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cat > ~/.config/systemd/user/docker.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Docker Rootless Daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$HOME/bin/dockerd-rootless.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
Environment=PATH=$HOME/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/{uid}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Enable and start the service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable docker.service
|
||||||
|
systemctl --user start docker.service
|
||||||
|
|
||||||
|
# Wait for socket
|
||||||
|
sleep 5
|
||||||
|
'"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run the installation as the user
|
||||||
|
result = subprocess.run(
|
||||||
|
['su', '-', username, '-c', install_cmd],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log output
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if line.strip():
|
||||||
|
logs.append(line.strip())
|
||||||
|
if result.stderr:
|
||||||
|
for line in result.stderr.split('\n')[-5:]:
|
||||||
|
if line.strip():
|
||||||
|
logs.append(f" stderr: {line.strip()}")
|
||||||
|
|
||||||
|
# Check if socket exists
|
||||||
|
sock = f"/run/user/{uid}/docker.sock"
|
||||||
|
if os.path.exists(sock):
|
||||||
|
logs.append(f"✅ Rootless Docker ready for {username}")
|
||||||
|
return True, "ok"
|
||||||
|
else:
|
||||||
|
logs.append(f"⚠️ Socket not found at {sock}")
|
||||||
|
return True, "socket_pending"
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logs.append("⚠️ Setup timed out after 300s")
|
||||||
|
return False, "timeout"
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f"⚠️ Setup failed: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(username, remove_home=False):
|
||||||
|
"""Remove a user. Returns (success, message)."""
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
pwd.getpwnam(username)
|
||||||
|
except KeyError:
|
||||||
|
return False, f"User '{username}' does not exist"
|
||||||
|
|
||||||
|
# Stop their docker service first
|
||||||
|
try:
|
||||||
|
pw = pwd.getpwnam(username)
|
||||||
|
_run(
|
||||||
|
f"XDG_RUNTIME_DIR=/run/user/{pw.pw_uid} "
|
||||||
|
f"su --login {username} --command 'systemctl --user stop docker-rootless.service 2>/dev/null' "
|
||||||
|
f"2>/dev/null"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_run(f"loginctl disable-linger {username} 2>/dev/null")
|
||||||
|
|
||||||
|
# Clean up virtual disk BEFORE userdel (userdel might complain if home is busy)
|
||||||
|
_remove_virtual_disk(username, logs)
|
||||||
|
|
||||||
|
flag = '-r' if remove_home else ''
|
||||||
|
out, err = _run(f"userdel {flag} {username}")
|
||||||
|
if err and 'does not exist' not in err and 'mail spool' not in err:
|
||||||
|
return False, f"userdel error: {err}"
|
||||||
|
|
||||||
|
msg = f"✅ User {username} deleted" + (" (home removed)" if remove_home else "")
|
||||||
|
logs.append(msg)
|
||||||
|
return True, '\n'.join(logs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_disk_usage(username):
|
||||||
|
try:
|
||||||
|
pw = pwd.getpwnam(username)
|
||||||
|
except KeyError:
|
||||||
|
return {}
|
||||||
|
total_out, _ = _run(f"du -sh {pw.pw_dir} 2>/dev/null | cut -f1")
|
||||||
|
|
||||||
|
# Also check if there's a virtual disk and its capacity
|
||||||
|
img_path = f"/home/{username}.img"
|
||||||
|
vdisk_info = {}
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
# Get mounted filesystem usage
|
||||||
|
df_out, _ = _run(f"df -h {pw.pw_dir} 2>/dev/null | tail -1")
|
||||||
|
if df_out:
|
||||||
|
parts = df_out.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
vdisk_info = {
|
||||||
|
'size': parts[1],
|
||||||
|
'used': parts[2],
|
||||||
|
'available': parts[3],
|
||||||
|
'use_pct': parts[4] if len(parts) > 4 else '?',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'home': pw.pw_dir,
|
||||||
|
'total': total_out or 'N/A',
|
||||||
|
'vdisk': vdisk_info,
|
||||||
|
}
|
||||||
283
platform/restore-myapps.sh
Executable file
283
platform/restore-myapps.sh
Executable file
@@ -0,0 +1,283 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================
|
||||||
|
# restore-myapps.sh — Complete Restore Script
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Parse arguments
|
||||||
|
# --------------------------------------------------
|
||||||
|
REMOTE_MODE=false
|
||||||
|
REMOTE_IP=""
|
||||||
|
REMOTE_USER="root"
|
||||||
|
SSH_KEY=""
|
||||||
|
USE_PASSWORD=false
|
||||||
|
SSH_PASS=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--remote)
|
||||||
|
REMOTE_MODE=true
|
||||||
|
REMOTE_IP="$2"
|
||||||
|
REMOTE_USER="${3:-root}"
|
||||||
|
shift 3
|
||||||
|
;;
|
||||||
|
--key)
|
||||||
|
SSH_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--password)
|
||||||
|
USE_PASSWORD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# If remote mode
|
||||||
|
# --------------------------------------------------
|
||||||
|
if [ "$REMOTE_MODE" = true ]; then
|
||||||
|
if [ -z "$REMOTE_IP" ]; then
|
||||||
|
echo "❌ --remote requires an IP address."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REMOTE_DEST="/tmp/restore-session-$(date +%s)"
|
||||||
|
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||||
|
if [ -n "$SSH_KEY" ]; then
|
||||||
|
SSH_OPTS="$SSH_OPTS -i $SSH_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$USE_PASSWORD" = true ]; then
|
||||||
|
if ! command -v sshpass &>/dev/null; then
|
||||||
|
apt install sshpass -y
|
||||||
|
fi
|
||||||
|
read -s -p "SSH password for ${REMOTE_USER}@${REMOTE_IP}: " SSH_PASS
|
||||||
|
echo ""
|
||||||
|
SSH_CMD="sshpass -p '$SSH_PASS' ssh $SSH_OPTS ${REMOTE_USER}@${REMOTE_IP}"
|
||||||
|
SCP_CMD="sshpass -p '$SSH_PASS' scp $SSH_OPTS"
|
||||||
|
else
|
||||||
|
SSH_CMD="ssh $SSH_OPTS ${REMOTE_USER}@${REMOTE_IP}"
|
||||||
|
SCP_CMD="scp $SSH_OPTS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "📡 REMOTE RESTORE to ${REMOTE_USER}@${REMOTE_IP}"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
$SSH_CMD "mkdir -p $REMOTE_DEST"
|
||||||
|
$SCP_CMD -r "$SCRIPT_DIR/." "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DEST}/"
|
||||||
|
$SSH_CMD "chmod +x $REMOTE_DEST/restore-myapps.sh && cd $REMOTE_DEST && bash restore-myapps.sh"
|
||||||
|
$SSH_CMD "rm -rf $REMOTE_DEST"
|
||||||
|
|
||||||
|
echo "✅ Remote restore complete"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===================================================
|
||||||
|
# LOCAL RESTORE
|
||||||
|
# ===================================================
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
VM_IP=$(ip -4 addr show | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v 127.0.0.1 | head -1)
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "🔄 FULL RESTORE — LOCAL MODE"
|
||||||
|
echo " Machine IP: $VM_IP"
|
||||||
|
echo " Backup dir: $SCRIPT_DIR"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# STEP 1: Restore Volumes
|
||||||
|
# --------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "📦 STEP 1 — Restoring Volumes"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
if [ -d "$SCRIPT_DIR/volumes" ]; then
|
||||||
|
cd "$SCRIPT_DIR/volumes"
|
||||||
|
for backup in *.tar.gz; do
|
||||||
|
[ -f "$backup" ] || continue
|
||||||
|
volume=$(basename "$backup" .tar.gz)
|
||||||
|
echo -n " 📁 Restoring $volume ... "
|
||||||
|
docker volume create "$volume" &>/dev/null || true
|
||||||
|
docker run --rm \
|
||||||
|
-v "${volume}:/target" \
|
||||||
|
-v "$(pwd):/backup" \
|
||||||
|
alpine \
|
||||||
|
sh -c "cd /target && tar xzf /backup/$backup" \
|
||||||
|
&& echo "✅" || echo "⚠️ FAILED"
|
||||||
|
done
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# STEP 2: Start Containers
|
||||||
|
# --------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "🚀 STEP 2 — Starting Containers"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
declare -A APP_DIRS=(
|
||||||
|
["Frappe"]="frappe-setup"
|
||||||
|
["Odoo"]="odoo-clean"
|
||||||
|
["Nextcloud"]="nextcloud-setup"
|
||||||
|
["Mautic"]="mautic-setup"
|
||||||
|
["n8n"]="n8n-setup"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -d "$SCRIPT_DIR/compose-files" ]; then
|
||||||
|
cd "$SCRIPT_DIR/compose-files"
|
||||||
|
for app in Frappe Odoo Nextcloud Mautic n8n; do
|
||||||
|
dir="${APP_DIRS[$app]}"
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
echo " 🚀 Starting $app..."
|
||||||
|
cd "$dir"
|
||||||
|
docker-compose up -d 2>&1 | tail -3
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "⏳ Waiting 60s for containers to initialize..."
|
||||||
|
sleep 60
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# STEP 3: Post-Restore Fixes
|
||||||
|
# --------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "🔧 STEP 3 — Applying Post-Restore Fixes"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# ---- NEXTCLOUD ----
|
||||||
|
echo ""
|
||||||
|
echo "📌 Nextcloud — Trusted domains..."
|
||||||
|
if docker ps | grep -q nextcloud-app; then
|
||||||
|
docker exec nextcloud-app php /var/www/html/occ config:system:delete trusted_domains 1 2>/dev/null || true
|
||||||
|
docker exec nextcloud-app php /var/www/html/occ config:system:delete trusted_domains 2 2>/dev/null || true
|
||||||
|
docker exec nextcloud-app php /var/www/html/occ config:system:set trusted_domains 0 --value="localhost"
|
||||||
|
docker exec nextcloud-app php /var/www/html/occ config:system:set trusted_domains 1 --value="$VM_IP"
|
||||||
|
docker exec nextcloud-app php /var/www/html/occ config:system:set trusted_domains 2 --value="${VM_IP}:8082"
|
||||||
|
docker exec nextcloud-app php /var/www/html/occ config:system:set trusted_domains 3 --value="localhost:8082"
|
||||||
|
docker restart nextcloud-app
|
||||||
|
echo " ✅ Nextcloud fixed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- MAUTIC ----
|
||||||
|
echo ""
|
||||||
|
echo "📌 Mautic — Config + admin password..."
|
||||||
|
if docker ps | grep -q mautic-app; then
|
||||||
|
# Create config file
|
||||||
|
cat > /tmp/local.php << EOF
|
||||||
|
<?php
|
||||||
|
\$parameters = array(
|
||||||
|
'db_driver' => 'pdo_mysql',
|
||||||
|
'db_host' => 'mautic-mariadb',
|
||||||
|
'db_port' => '3306',
|
||||||
|
'db_name' => 'mautic',
|
||||||
|
'db_user' => 'mautic',
|
||||||
|
'db_password' => 'mautic123',
|
||||||
|
'site_url' => 'http://${VM_IP}:8081'
|
||||||
|
);
|
||||||
|
EOF
|
||||||
|
docker cp /tmp/local.php mautic-app:/var/www/html/config/local.php 2>/dev/null
|
||||||
|
docker exec mautic-app touch /var/www/html/var/.installed 2>/dev/null
|
||||||
|
docker exec mautic-app chown -R www-data:www-data /var/www/html/var 2>/dev/null
|
||||||
|
docker exec mautic-app chown -R www-data:www-data /var/www/html/config 2>/dev/null
|
||||||
|
docker restart mautic-app
|
||||||
|
sleep 10
|
||||||
|
HASH=$(docker exec mautic-app php -r "echo password_hash('Admin!Password123', PASSWORD_BCRYPT);" 2>/dev/null)
|
||||||
|
if [ ! -z "$HASH" ]; then
|
||||||
|
docker exec mautic-mariadb mysql -uroot -pmautic_root_password -e "USE mautic; UPDATE users SET password = '$HASH' WHERE username = 'admin';" 2>/dev/null
|
||||||
|
echo " ✅ Admin password reset to: Admin!Password123"
|
||||||
|
fi
|
||||||
|
docker restart mautic-app
|
||||||
|
echo " ✅ Mautic fixed → http://${VM_IP}:8081 (admin/Admin!Password123)"
|
||||||
|
else
|
||||||
|
echo " ⚠️ mautic-app not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- ODOO ----
|
||||||
|
echo ""
|
||||||
|
echo "📌 Odoo — Assets + DB user..."
|
||||||
|
if docker ps | grep -q odoo-clean-db-1; then
|
||||||
|
docker exec odoo-clean-db-1 psql -U odoo -d odoo -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';" 2>/dev/null
|
||||||
|
docker exec odoo-clean-db-1 psql -U odoo -c "ALTER USER odoo WITH PASSWORD 'odoo';" 2>/dev/null
|
||||||
|
docker exec odoo-clean-odoo-1 bash -c "grep -q 'filestore_check_missing' /etc/odoo/odoo.conf || echo 'filestore_check_missing = False' >> /etc/odoo/odoo.conf" 2>/dev/null
|
||||||
|
docker restart odoo-clean-odoo-1
|
||||||
|
echo " ✅ Odoo fixed → http://${VM_IP}:8069/web"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- FRAPPE ----
|
||||||
|
echo ""
|
||||||
|
echo "📌 Frappe — Full fix..."
|
||||||
|
if docker ps | grep -q frappe-erpnext && docker ps | grep -q frappe-mariadb; then
|
||||||
|
DB_NAME=$(docker exec frappe-erpnext cat /home/frappe/frappe-bench/sites/erpnext.navitrends.ovh/site_config.json 2>/dev/null | grep -o '"db_name": *"[^"]*"' | cut -d'"' -f4)
|
||||||
|
DB_PASS=$(docker exec frappe-erpnext cat /home/frappe/frappe-bench/sites/erpnext.navitrends.ovh/site_config.json 2>/dev/null | grep -o '"db_password": *"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -n "$DB_NAME" ] && [ -n "$DB_PASS" ]; then
|
||||||
|
echo " DB: $DB_NAME"
|
||||||
|
docker exec frappe-mariadb mysql -uroot -p123 -e "
|
||||||
|
GRANT ALL PRIVILEGES ON *.* TO '${DB_NAME}'@'%' IDENTIFIED BY '${DB_PASS}' WITH GRANT OPTION;
|
||||||
|
GRANT ALL PRIVILEGES ON *.* TO '${DB_NAME}'@'172.%' IDENTIFIED BY '${DB_PASS}' WITH GRANT OPTION;
|
||||||
|
GRANT ALL PRIVILEGES ON *.* TO '${DB_NAME}'@'localhost' IDENTIFIED BY '${DB_PASS}' WITH GRANT OPTION;
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
" 2>/dev/null && echo " ✅ DB permissions fixed"
|
||||||
|
|
||||||
|
docker exec frappe-erpnext bash -c "
|
||||||
|
cd /home/frappe/frappe-bench
|
||||||
|
if [ ! -d 'apps/hrms' ]; then
|
||||||
|
bench get-app --branch version-15 https://github.com/frappe/hrms
|
||||||
|
fi
|
||||||
|
bench --site erpnext.navitrends.ovh install-app hrms
|
||||||
|
" 2>/dev/null && echo " ✅ HRMS installed"
|
||||||
|
|
||||||
|
docker exec frappe-erpnext bash -c "
|
||||||
|
cd /home/frappe/frappe-bench
|
||||||
|
bench --site erpnext.navitrends.ovh set-config redis_cache 'redis://frappe-redis:6379'
|
||||||
|
bench --site erpnext.navitrends.ovh set-config redis_queue 'redis://frappe-redis:6379'
|
||||||
|
bench --site erpnext.navitrends.ovh set-config enable_scheduler 1
|
||||||
|
bench --site erpnext.navitrends.ovh set-config site_url 'http://${VM_IP}:8080'
|
||||||
|
bench --site erpnext.navitrends.ovh migrate
|
||||||
|
bench --site erpnext.navitrends.ovh clear-cache
|
||||||
|
bench use erpnext.navitrends.ovh
|
||||||
|
" 2>/dev/null && echo " ✅ Frappe configured"
|
||||||
|
|
||||||
|
# Start bench serve in background inside container
|
||||||
|
docker exec -d frappe-erpnext bash -c "cd /home/frappe/frappe-bench && nohup bench serve --port 8000 > /tmp/bench.log 2>&1 &"
|
||||||
|
docker restart frappe-erpnext
|
||||||
|
echo " ✅ Frappe fixed → http://${VM_IP}:8080"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Could not get Frappe credentials"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- N8N ----
|
||||||
|
echo ""
|
||||||
|
echo "📌 n8n — Network check..."
|
||||||
|
docker network inspect integration-network &>/dev/null || docker network create integration-network
|
||||||
|
echo " ✅ n8n → http://${VM_IP}:5678"
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# --------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo "✅ RESTORE COMPLETE"
|
||||||
|
echo "========================================="
|
||||||
|
echo " Nextcloud → http://${VM_IP}:8082"
|
||||||
|
echo " Mautic → http://${VM_IP}:8081 (admin/Admin!Password123)"
|
||||||
|
echo " Odoo → http://${VM_IP}:8069/web"
|
||||||
|
echo " n8n → http://${VM_IP}:5678"
|
||||||
|
echo " Frappe → http://${VM_IP}:8080"
|
||||||
|
echo "========================================="
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +1,108 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Navitrends - Management Platform</title>
|
<title>Navitrends — Ops Platform</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Geist+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
|
||||||
<div class="sidebar-header">
|
<!-- ── SIDEBAR ── -->
|
||||||
<h2>Navitrends</h2>
|
<aside class="sidebar">
|
||||||
<p>Management Platform</p>
|
<div class="sidebar-brand">
|
||||||
</div>
|
<div class="brand-mark">NV</div>
|
||||||
<div class="nav-menu">
|
<div>
|
||||||
<div class="nav-item active" data-page="dashboard">
|
<div class="brand-name">Navitrends</div>
|
||||||
<i class="fas fa-chart-line nav-icon"></i>
|
<div class="brand-sub">OPS PLATFORM</div>
|
||||||
<span>Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item" data-page="restore">
|
|
||||||
<i class="fas fa-rotate-right nav-icon"></i>
|
|
||||||
<span>Restore Actions</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item" data-page="backups">
|
|
||||||
<i class="fas fa-database nav-icon"></i>
|
|
||||||
<span>Backups</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item" data-page="settings">
|
|
||||||
<i class="fas fa-sliders-h nav-icon"></i>
|
|
||||||
<span>Settings</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="main-content">
|
<nav class="nav">
|
||||||
<div class="top-bar">
|
<div class="nav-section-label">MONITOR</div>
|
||||||
<h3 id="page-title">Dashboard Overview</h3>
|
<a class="nav-item active" data-page="dashboard" href="#">
|
||||||
<div class="server-status">
|
<i class="fas fa-gauge-high"></i><span>Dashboard</span>
|
||||||
<div class="status-indicator">
|
</a>
|
||||||
<span class="status-dot" id="server-status-dot"></span>
|
<a class="nav-item" data-page="containers" href="#">
|
||||||
<span id="server-status-text">Checking...</span>
|
<i class="fas fa-cubes"></i><span>All Containers</span>
|
||||||
</div>
|
<span class="nav-badge" id="nav-badge-containers">—</span>
|
||||||
<button class="btn btn-secondary" onclick="refreshStatus()">
|
|
||||||
<i class="fas fa-sync-alt"></i> Refresh
|
|
||||||
</button>
|
|
||||||
<a href="/logout">
|
|
||||||
<button class="btn btn-secondary">
|
|
||||||
<i class="fas fa-sign-out-alt"></i> Logout
|
|
||||||
</button>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="nav-section-label" style="margin-top:20px;">OPERATIONS</div>
|
||||||
|
<a class="nav-item" data-page="restore" href="#">
|
||||||
|
<i class="fas fa-rotate-right"></i><span>Restore</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-page="backups" href="#">
|
||||||
|
<i class="fas fa-database"></i><span>Backups</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section-label" style="margin-top:20px;">ADMIN</div>
|
||||||
|
<a class="nav-item" data-page="users" href="#">
|
||||||
|
<i class="fas fa-users-gear"></i><span>Users</span>
|
||||||
|
<span class="nav-badge" id="nav-badge-users">—</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-page="settings" href="#">
|
||||||
|
<i class="fas fa-sliders"></i><span>Settings</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="server-pill" id="server-pill">
|
||||||
|
<span class="pulse-dot" id="pulse-dot"></span>
|
||||||
|
<span id="server-status-text">Checking…</span>
|
||||||
|
</div>
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<button class="theme-toggle" id="theme-toggle" title="Toggle light/dark mode" onclick="toggleTheme()">
|
||||||
|
<i class="fas fa-moon" id="theme-icon"></i>
|
||||||
|
</button>
|
||||||
|
<a href="/logout" class="logout-btn" title="Logout"><i class="fas fa-right-from-bracket"></i></a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── MAIN ── -->
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<h1 class="page-title" id="page-title">Dashboard</h1>
|
||||||
|
<span class="page-subtitle" id="page-subtitle">{{ main_server }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button class="icon-btn" onclick="refreshAll()" title="Refresh">
|
||||||
|
<i class="fas fa-sync-alt" id="refresh-icon"></i>
|
||||||
|
</button>
|
||||||
|
<div class="uptime-chip" id="uptime-chip">—</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<i class="fas fa-shield-alt"></i> Navitrends Management Platform
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// ── Theme persistence ───────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const saved = localStorage.getItem('nv-theme') || 'dark';
|
||||||
|
applyTheme(saved);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
const icon = document.getElementById('theme-icon');
|
||||||
|
if (icon) icon.className = theme === 'dark' ? 'fas fa-moon' : 'fas fa-sun';
|
||||||
|
localStorage.setItem('nv-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,48 +4,76 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Navitrends — Login</title>
|
<title>Navitrends — Login</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Syne', sans-serif;
|
||||||
display: flex; justify-content: center; align-items: center;
|
background: #0a0b0e;
|
||||||
min-height: 100vh;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4338ca 100%);
|
min-height: 100vh; color: #e8ecf4;
|
||||||
}
|
}
|
||||||
.login-card {
|
body::before {
|
||||||
background: white; border-radius: 24px;
|
content: ''; position: fixed; inset: 0;
|
||||||
padding: 48px 40px; width: 380px;
|
background-image:
|
||||||
text-align: center;
|
linear-gradient(rgba(59,130,246,0.03) 1px, transparent 1px),
|
||||||
box-shadow: 0 25px 60px rgba(0,0,0,0.3);
|
linear-gradient(90deg, rgba(59,130,246,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px; pointer-events: none;
|
||||||
}
|
}
|
||||||
h1 { font-size: 26px; font-weight: 700; color: #1e1b4b; }
|
.card {
|
||||||
p { color: #6b7280; margin: 6px 0 28px; font-size: 14px; }
|
background: #111318; border: 1px solid #1e2330;
|
||||||
input {
|
border-radius: 20px; padding: 44px 40px; width: 380px; position: relative;
|
||||||
width: 100%; padding: 13px 16px; margin-bottom: 12px;
|
|
||||||
border: 1.5px solid #e5e7eb; border-radius: 12px;
|
|
||||||
font-size: 14px; transition: border-color 0.15s;
|
|
||||||
}
|
}
|
||||||
input:focus { outline: none; border-color: #6366f1; }
|
.card::before {
|
||||||
|
content: ''; position: absolute; top: 0; left: 40px; right: 40px; height: 2px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #60a5fa, #a78bfa);
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 32px; }
|
||||||
|
.brand-mark {
|
||||||
|
width: 40px; height: 40px; background: #3b82f6; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: 'Geist Mono', monospace; font-weight: 500; font-size: 13px; color: #fff;
|
||||||
|
}
|
||||||
|
.brand-name { font-size: 18px; font-weight: 700; }
|
||||||
|
.brand-sub { font-size: 11px; color: #4a5568; font-family: 'Geist Mono', monospace; letter-spacing: 0.08em; }
|
||||||
|
label { display: block; font-size: 11px; font-weight: 600; color: #8892a4; letter-spacing: 0.1em; margin-bottom: 7px; }
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%; padding: 11px 14px; background: #181c24;
|
||||||
|
border: 1px solid #262d3d; border-radius: 10px;
|
||||||
|
font-size: 14px; font-family: 'Geist Mono', monospace;
|
||||||
|
color: #e8ecf4; letter-spacing: 0.1em; margin-bottom: 20px;
|
||||||
|
transition: border-color 0.18s;
|
||||||
|
}
|
||||||
|
input[type="password"]:focus { outline: none; border-color: #3b82f6; }
|
||||||
button {
|
button {
|
||||||
width: 100%; padding: 13px;
|
width: 100%; padding: 12px; background: #3b82f6; color: #fff;
|
||||||
background: #6366f1; color: white;
|
border: none; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||||
border: none; border-radius: 12px;
|
font-family: 'Syne', sans-serif; cursor: pointer; transition: background 0.18s;
|
||||||
font-size: 15px; font-weight: 600;
|
}
|
||||||
cursor: pointer; transition: background 0.15s;
|
button:hover { background: #2563eb; }
|
||||||
|
.error {
|
||||||
|
color: #ef4444; font-size: 12px; margin-bottom: 14px;
|
||||||
|
padding: 9px 12px; background: rgba(239,68,68,0.08);
|
||||||
|
border: 1px solid rgba(239,68,68,0.2); border-radius: 8px;
|
||||||
}
|
}
|
||||||
button:hover { background: #4f46e5; }
|
|
||||||
.error { color: #ef4444; font-size: 13px; margin-bottom: 12px; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="login-card">
|
<div class="card">
|
||||||
<h1>Navitrends</h1>
|
<div class="brand">
|
||||||
<p>Management Platform</p>
|
<div class="brand-mark">NV</div>
|
||||||
{% if error %}<div class="error">⚠️ {{ error }}</div>{% endif %}
|
<div>
|
||||||
|
<div class="brand-name">Navitrends</div>
|
||||||
|
<div class="brand-sub">OPS PLATFORM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if error %}<div class="error">⚠ {{ error }}</div>{% endif %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="password" name="password" placeholder="Enter password" required autofocus>
|
<label>ACCESS PASSWORD</label>
|
||||||
<button type="submit">Login →</button>
|
<input type="password" name="password" placeholder="••••••••••" required autofocus>
|
||||||
|
<button type="submit">Sign In →</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user