Sync from main server - 2026-04-15 13:03:38

This commit is contained in:
root
2026-04-15 13:03:38 +02:00
parent 3f3d3a9fc3
commit 68870eb3db
8 changed files with 2455 additions and 1212 deletions

View File

@@ -4,10 +4,17 @@ import subprocess
import threading
import uuid
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.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.users import (
get_all_users, get_user_containers, get_all_users_containers,
create_user, delete_user, get_user_disk_usage
)
app = Flask(__name__)
app.secret_key = 'navitrends-secret-key-2025'
@@ -33,6 +40,9 @@ def _stream_restore(job_id, cmd):
restore_jobs[job_id]['status'] = 'error'
# ─────────────────────────────────────────────
# DASHBOARD
# ─────────────────────────────────────────────
@app.route('/')
@login_required
def dashboard():
@@ -40,14 +50,121 @@ def dashboard():
running_count = sum(1 for c in containers if 'Up' in c.get('status', ''))
backups = get_local_backups()
vm_backups = get_vm_backups()
system = get_system_info()
users = get_all_users()
return render_template('dashboard.html',
containers=containers,
running_count=running_count,
backups=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'])
@login_required
def restore_start():
@@ -68,41 +185,22 @@ def restore_start():
if not backup_file:
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':
# Local path depends on which server we're running on
if RUNNING_ON_MAIN_SERVER:
backup_path = f"/root/backups/{backup_file}"
else:
backup_path = f"/backups/main-server/{backup_file}"
backup_path = f"/root/backups/{backup_file}"
if not os.path.exists(backup_path):
return jsonify({'error': f'Backup not found: {backup_path}'}), 400
else:
# "Other server" backup source — pull it to /tmp/ first
backup_path = f"/tmp/{backup_file}"
if not os.path.exists(backup_path):
if RUNNING_ON_MAIN_SERVER:
# Original logic: pull from VM via SSH tunnel (port 2223)
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"{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}"
)
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} {backup_path}"
)
result = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
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(
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):
return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500
# ── Build command ────────────────────────────────────────────────────────
if target == 'local':
session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}"
cmd = (
f"set -e && "
f"mkdir -p {session_dir} && "
f"set -e && mkdir -p {session_dir} && "
f"echo '📂 Extracting backup locally...' && "
f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && "
f"cp {restore_script_local} {session_dir}/restore-myapps.sh && "
f"chmod +x {session_dir}/restore-myapps.sh && "
f"cd {session_dir} && "
f"bash restore-myapps.sh ; "
f"cd {session_dir} && bash restore-myapps.sh ; "
f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT"
)
else:
if not remote_ip:
return jsonify({'error': 'remote_ip is required for remote restore'}), 400
base_ssh_opts = f"-o StrictHostKeyChecking=no -o ConnectTimeout=15"
return jsonify({'error': 'remote_ip required for remote restore'}), 400
base_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=15"
if auth_method == 'key':
if not ssh_key_path:
return jsonify({'error': 'ssh_key_path is required'}), 400
ssh_prefix = f"ssh -p {remote_port} -i {ssh_key_path} {base_ssh_opts}"
scp_prefix = f"scp -P {remote_port} -i {ssh_key_path} {base_ssh_opts}"
return jsonify({'error': 'ssh_key_path required'}), 400
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_opts}"
else:
if not ssh_password:
return jsonify({'error': 'ssh_password is required'}), 400
ssh_prefix = f"sshpass -p '{ssh_password}' ssh -p {remote_port} {base_ssh_opts}"
scp_prefix = f"sshpass -p '{ssh_password}' scp -P {remote_port} {base_ssh_opts}"
return jsonify({'error': 'ssh_password required'}), 400
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_opts}"
remote_dest = f"/backups/restore-session-{uuid.uuid4().hex[:8]}"
cmd = (
f"echo '🔗 Connecting to {remote_user}@{remote_ip}:{remote_port}...' && "
f"{ssh_prefix} {remote_user}@{remote_ip} 'mkdir -p {remote_dest}' && "
f"echo '✅ Connected.' && "
f"echo '📤 Copying backup archive to {remote_ip}:{remote_port}...' && "
f"echo '📤 Copying backup archive...' && "
f"{scp_prefix} {backup_path} {remote_user}@{remote_ip}:{remote_dest}/{backup_file} && "
f"echo '📤 Copying restore script...' && "
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"{ssh_prefix} {remote_user}@{remote_ip} "
f"'set -e && "
f"cd {remote_dest} && "
f"'set -e && cd {remote_dest} && "
f"echo \"📂 Extracting backup...\" && "
f"tar -xzf {backup_file} --strip-components=1 && "
f"chmod +x restore-myapps.sh && "
f"bash restore-myapps.sh' ; "
f"chmod +x restore-myapps.sh && bash restore-myapps.sh' ; "
f"EXIT=$? ; "
f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; "
f"exit $EXIT"
@@ -172,7 +258,6 @@ def restore_start():
job_id = str(uuid.uuid4())
t = threading.Thread(target=_stream_restore, args=(job_id, cmd), daemon=True)
t.start()
return jsonify({'job_id': job_id, 'status': 'started'})
@@ -189,22 +274,9 @@ def restore_status_poll(job_id):
})
@app.route('/api/backups')
@login_required
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', ''))
})
# ─────────────────────────────────────────────
# SERVER STATUS
# ─────────────────────────────────────────────
@app.route('/server/status')
@login_required
def server_status():
@@ -214,6 +286,9 @@ def server_status():
return jsonify({'status': 'online', 'info': stdout.strip()})
# ─────────────────────────────────────────────
# AUTH
# ─────────────────────────────────────────────
@app.route('/login', methods=['GET', 'POST'])
def login():
error = ''

View File

@@ -1,31 +1,27 @@
import os
import glob
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:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
return result.stdout, result.stderr
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip(), r.stderr.strip()
except Exception as e:
return '', str(e)
def get_local_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"
# ────────────────────────────────────────────────────────────────
# BACKUPS
# ────────────────────────────────────────────────────────────────
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 = []
if stdout:
for line in stdout.strip().split('\n'):
for line in stdout.split('\n'):
line = line.strip()
if line:
files.append(os.path.basename(line))
@@ -33,14 +29,8 @@ def get_local_backups():
def get_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 = []
vm_backups = []
if RUNNING_ON_MAIN_SERVER:
# Original logic — unchanged
try:
cmd = (
f"ssh -i {VM_KEY} -p {VM_PORT} "
@@ -48,58 +38,143 @@ def get_vm_backups():
f"{VM_USER}@{VM_HOST} "
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:
for line in stdout.strip().split('\n'):
for line in stdout.split('\n'):
line = line.strip()
if line and '.tar.gz' in line:
backups.append(os.path.basename(line))
vm_backups.append(os.path.basename(line))
except Exception as e:
print(f"[backups] Error fetching VM backups: {e}")
print(f"[backups] VM backup fetch error: {e}")
else:
# Running on VM → SSH into main server to list its local backups
try:
cmd = (
f"ssh -i {VM_KEY} -p 22 "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
f"{VM_USER}@{MAIN_SERVER_IP} "
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}")
backup_dir = '/backups/main-server'
if os.path.exists(backup_dir):
files = glob.glob(f'{backup_dir}/myapps-backup-*.tar.gz')
files.sort(key=os.path.getmtime, reverse=True)
vm_backups = [os.path.basename(f) for f in files[:20]]
return vm_backups
return backups
# ────────────────────────────────────────────────────────────────
# ROOT CONTAINERS
# ────────────────────────────────────────────────────────────────
def get_containers():
"""
On main server → local docker ps (original, unchanged)
On VM → SSH into main server to get its containers
"""
if RUNNING_ON_MAIN_SERVER:
stdout, _ = run_command(
"docker ps -a --format '{{.Names}}|{{.Status}}' 2>/dev/null"
"""Root app containers only (filtered)."""
stdout, _ = _run(
"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null | "
"grep -E 'frappe|nextcloud|mautic|n8n|odoo'"
)
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
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:
cmd = (
f"ssh -i {VM_KEY} -p 22 "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
f"{VM_USER}@{MAIN_SERVER_IP} "
f"\"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}' 2>/dev/null\""
"docker stats --no-stream --format "
"'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null"
)
stdout, _ = run_command(cmd)
containers = []
stdout, _ = _run(cmd, timeout=30)
stats = {}
if stdout:
for line in stdout.strip().split('\n'):
if '|' in line:
name, status = line.split('|', 1)
containers.append({'name': name.strip(), 'status': status.strip()})
return containers
for line in stdout.split('\n'):
if '|' not in line:
continue
parts = line.split('|')
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
View 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
View 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

View File

@@ -1,67 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Navitrends - Management 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">
<title>Navitrends — Ops Platform</title>
<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="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="app">
<div class="sidebar">
<div class="sidebar-header">
<h2>Navitrends</h2>
<p>Management Platform</p>
</div>
<div class="nav-menu">
<div class="nav-item active" data-page="dashboard">
<i class="fas fa-chart-line nav-icon"></i>
<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 class="layout">
<div class="main-content">
<div class="top-bar">
<h3 id="page-title">Dashboard Overview</h3>
<div class="server-status">
<div class="status-indicator">
<span class="status-dot" id="server-status-dot"></span>
<span id="server-status-text">Checking...</span>
</div>
<button class="btn btn-secondary" onclick="refreshStatus()">
<i class="fas fa-sync-alt"></i> Refresh
<!-- ── SIDEBAR ── -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-mark">NV</div>
<div>
<div class="brand-name">Navitrends</div>
<div class="brand-sub">OPS PLATFORM</div>
</div>
</div>
<nav class="nav">
<div class="nav-section-label">MONITOR</div>
<a class="nav-item active" data-page="dashboard" href="#">
<i class="fas fa-gauge-high"></i><span>Dashboard</span>
</a>
<a class="nav-item" data-page="containers" href="#">
<i class="fas fa-cubes"></i><span>All Containers</span>
<span class="nav-badge" id="nav-badge-containers"></span>
</a>
<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>
<a href="/logout">
<button class="btn btn-secondary">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</a>
<div class="uptime-chip" id="uptime-chip"></div>
</div>
</div>
</header>
{% block content %}{% endblock %}
<div class="footer">
<i class="fas fa-shield-alt"></i> Navitrends Management Platform
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
</main>
</div>
<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>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -4,48 +4,76 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4338ca 100%);
font-family: 'Syne', sans-serif;
background: #0a0b0e;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; color: #e8ecf4;
}
.login-card {
background: white; border-radius: 24px;
padding: 48px 40px; width: 380px;
text-align: center;
box-shadow: 0 25px 60px rgba(0,0,0,0.3);
body::before {
content: ''; position: fixed; inset: 0;
background-image:
linear-gradient(rgba(59,130,246,0.03) 1px, transparent 1px),
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; }
p { color: #6b7280; margin: 6px 0 28px; font-size: 14px; }
input {
width: 100%; padding: 13px 16px; margin-bottom: 12px;
border: 1.5px solid #e5e7eb; border-radius: 12px;
font-size: 14px; transition: border-color 0.15s;
.card {
background: #111318; border: 1px solid #1e2330;
border-radius: 20px; padding: 44px 40px; width: 380px; position: relative;
}
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 {
width: 100%; padding: 13px;
background: #6366f1; color: white;
border: none; border-radius: 12px;
font-size: 15px; font-weight: 600;
cursor: pointer; transition: background 0.15s;
width: 100%; padding: 12px; background: #3b82f6; color: #fff;
border: none; border-radius: 10px; font-size: 14px; font-weight: 600;
font-family: 'Syne', sans-serif; cursor: pointer; transition: background 0.18s;
}
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>
</head>
<body>
<div class="login-card">
<h1>Navitrends</h1>
<p>Management Platform</p>
{% if error %}<div class="error">⚠️ {{ error }}</div>{% endif %}
<div class="card">
<div class="brand">
<div class="brand-mark">NV</div>
<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">
<input type="password" name="password" placeholder="Enter password" required autofocus>
<button type="submit">Login →</button>
<label>ACCESS PASSWORD</label>
<input type="password" name="password" placeholder="••••••••••" required autofocus>
<button type="submit">Sign In →</button>
</form>
</div>
</body>