diff --git a/platform/app.py b/platform/app.py index fd59c90..b99ce34 100644 --- a/platform/app.py +++ b/platform/app.py @@ -17,7 +17,9 @@ from modules.backups import ( get_local_backups, get_vm_backups, get_all_stats, get_system_info, get_rootless_user_containers_remote, - container_action, + container_action, get_container_status, + audit_backup, delete_backup, + get_backup_log_entries, get_backup_script_path, ) from modules.commands import run_command from modules.users import ( @@ -28,7 +30,8 @@ from modules.users import ( app = Flask(__name__) app.secret_key = 'navitrends-secret-key-2025' -restore_jobs = {} +restore_jobs = {} +backup_jobs = {} # for manual backup runs def _stream_restore(job_id, cmd): @@ -49,6 +52,25 @@ def _stream_restore(job_id, cmd): restore_jobs[job_id]['status'] = 'error' +def _stream_backup(job_id, script_path): + """Run the backup script and stream its output into backup_jobs.""" + backup_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()} + try: + proc = subprocess.Popen( + ['bash', script_path], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1 + ) + for line in proc.stdout: + backup_jobs[job_id]['log'].append(line.rstrip()) + proc.wait() + backup_jobs[job_id]['status'] = 'done' if proc.returncode == 0 else 'error' + backup_jobs[job_id]['returncode'] = proc.returncode + except Exception as e: + backup_jobs[job_id]['log'].append(f"ERROR: {e}") + backup_jobs[job_id]['status'] = 'error' + + # ───────────────────────────────────────────── # DASHBOARD # ───────────────────────────────────────────── @@ -60,9 +82,8 @@ def dashboard(): backups = get_local_backups() vm_backups = get_vm_backups() system = get_system_info() - # Users are still LOCAL (users on the platform host) users = get_all_users() - return render_template('dashboard.html', + return render_template('pages/dashboard.html', containers=containers, running_count=running_count, backups=backups, @@ -70,7 +91,82 @@ def dashboard(): main_server=MAIN_SERVER_IP, system=system, users=users, - running_on_main=RUNNING_ON_MAIN_SERVER) + running_on_main=RUNNING_ON_MAIN_SERVER, + active_page='dashboard', + page_title='Dashboard', + page_subtitle=MAIN_SERVER_IP) + + +@app.route('/containers') +@login_required +def containers_page(): + return render_template( + 'pages/containers.html', + main_server=MAIN_SERVER_IP, + active_page='containers', + page_title='All Containers', + page_subtitle='main server · all users' + ) + + +@app.route('/backups') +@login_required +def backups_page(): + return render_template( + 'pages/backups.html', + backups=get_local_backups(), + vm_backups=get_vm_backups(), + main_server=MAIN_SERVER_IP, + active_page='backups', + page_title='Backup Management', + page_subtitle='local & VM' + ) + + +@app.route('/restore') +@login_required +def restore_page(): + prefill = { + 'source': request.args.get('source', '').strip(), + 'file': request.args.get('file', '').strip(), + } + return render_template( + 'pages/restore.html', + backups=get_local_backups(), + vm_backups=get_vm_backups(), + restore_prefill=prefill, + main_server=MAIN_SERVER_IP, + active_page='restore', + page_title='Restore', + page_subtitle='backup → target' + ) + + +@app.route('/users') +@login_required +def users_page(): + return render_template( + 'pages/users.html', + users=get_all_users(), + main_server=MAIN_SERVER_IP, + active_page='users', + page_title='User Management', + page_subtitle='linux + docker' + ) + + +@app.route('/settings') +@login_required +def settings_page(): + return render_template( + 'pages/settings.html', + main_server=MAIN_SERVER_IP, + system=get_system_info(), + running_on_main=RUNNING_ON_MAIN_SERVER, + active_page='settings', + page_title='Settings', + page_subtitle='platform config' + ) # ───────────────────────────────────────────── @@ -107,6 +203,20 @@ def api_containers_all(): return jsonify({'containers': all_ctrs, 'running': running}) +@app.route('/api/nav-summary') +@login_required +def api_nav_summary(): + """Lightweight counts for sidebar badges on every page (one round trip).""" + root_ctrs = get_all_root_containers() + user_ctrs = get_rootless_user_containers_remote() + all_ctrs = root_ctrs + user_ctrs + users = get_all_users() + return jsonify({ + 'container_count': len(all_ctrs), + 'user_count': len(users), + }) + + # ───────────────────────────────────────────── # API — container actions # ───────────────────────────────────────────── @@ -115,7 +225,8 @@ def api_containers_all(): def api_container_action(): """ POST JSON: { "name": "container-name", "action": "start|stop|restart" } - Runs the action on the main server (via SSH if on VM). + Runs the action, then immediately returns the NEW container status so the + UI can update without waiting for the next 15-second refresh cycle. """ data = request.get_json() or {} name = data.get('name', '').strip() @@ -125,7 +236,28 @@ def api_container_action(): return jsonify({'success': False, 'message': 'name and action required'}), 400 success, output = container_action(name, action) - return jsonify({'success': success, 'output': output}) + + # Give Docker a moment to settle, then fetch the real status + time.sleep(1.5) + status_info = get_container_status(name) + + return jsonify({ + 'success': success, + 'output': output, + 'new_status': status_info['status'], # 'running' | 'stopped' | 'unknown' + 'new_status_raw': status_info['raw'], + }) + + +# ───────────────────────────────────────────── +# API — single container status (for polling) +# ───────────────────────────────────────────── +@app.route('/api/container/status/') +@login_required +def api_container_status(name): + """Quick single-container status check.""" + status_info = get_container_status(name) + return jsonify(status_info) # ───────────────────────────────────────────── @@ -137,6 +269,100 @@ def api_backups(): return jsonify({'local': get_local_backups(), 'vm': get_vm_backups()}) +@app.route('/api/backups/log') +@login_required +def api_backup_log(): + """Return the last N backup log entries.""" + limit = int(request.args.get('limit', 20)) + entries = get_backup_log_entries(limit) + return jsonify({'entries': entries}) + + +# ───────────────────────────────────────────── +# API — backup health audit +# ───────────────────────────────────────────── +@app.route('/api/backups/audit', methods=['POST']) +@login_required +def api_backup_audit(): + """ + POST JSON: { "backup_file": "myapps-backup-…tar.gz", "source": "local"|"vm" } + Returns full audit report. + """ + data = request.get_json() or {} + bfile = data.get('backup_file', '').strip() + source = data.get('source', 'local').strip() + + if not bfile: + return jsonify({'error': 'backup_file required'}), 400 + + result = audit_backup(bfile, source) + return jsonify(result) + + +# ───────────────────────────────────────────── +# API — delete backup +# ───────────────────────────────────────────── +@app.route('/api/backups/delete', methods=['POST']) +@login_required +def api_backup_delete(): + """ + POST JSON: { "backup_file": "myapps-backup-…tar.gz", "source": "local"|"vm" } + """ + data = request.get_json() or {} + bfile = data.get('backup_file', '').strip() + source = data.get('source', 'local').strip() + + if not bfile: + return jsonify({'success': False, 'message': 'backup_file required'}), 400 + + success, message = delete_backup(bfile, source) + return jsonify({'success': success, 'message': message}) + + +# ───────────────────────────────────────────── +# API — manual backup trigger +# ───────────────────────────────────────────── +@app.route('/api/backups/run', methods=['POST']) +@login_required +def api_backup_run(): + """ + Trigger a manual backup run on the main server. + Returns a job_id so the UI can poll /api/backups/run/status/. + Only works when running on the main server (where the backup script lives). + """ + if not RUNNING_ON_MAIN_SERVER: + return jsonify({ + 'success': False, + 'message': 'Manual backup can only be triggered from the main server platform.' + }), 400 + + script = '/root/backup-myapps.sh' + if not os.path.exists(script): + return jsonify({ + 'success': False, + 'message': f'Backup script not found at {script}' + }), 500 + + job_id = str(uuid.uuid4()) + t = threading.Thread(target=_stream_backup, args=(job_id, script), daemon=True) + t.start() + return jsonify({'success': True, 'job_id': job_id, 'status': 'started'}) + + +@app.route('/api/backups/run/status/') +@login_required +def api_backup_run_status(job_id): + """Poll manual backup job status.""" + job = backup_jobs.get(job_id) + if not job: + return jsonify({'error': 'Job not found'}), 404 + return jsonify({ + 'status': job['status'], + 'log': job['log'], + 'elapsed': round(time.time() - job.get('started', time.time())) + }) + + # ───────────────────────────────────────────── # API — users (LOCAL — users on this host) # ───────────────────────────────────────────── @@ -218,13 +444,11 @@ def restore_start(): # ── Resolve backup archive path ────────────────────────────────────────── if backup_source == 'local': - # Backup is on main server at /root/backups/ if RUNNING_ON_MAIN_SERVER: backup_path = f"/root/backups/{backup_file}" if not os.path.exists(backup_path): return jsonify({'error': f'Not found: {backup_path}'}), 400 else: - # We're on VM → need to pull backup from main server to /tmp/ first backup_path = f"/tmp/{backup_file}" if not os.path.exists(backup_path): pull_cmd = ( @@ -237,9 +461,7 @@ def restore_start(): if res.returncode != 0: return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500 else: - # VM backup if RUNNING_ON_MAIN_SERVER: - # Pull from VM via tunnel backup_path = f"/tmp/{backup_file}" if not os.path.exists(backup_path): pull_cmd = ( @@ -252,7 +474,6 @@ def restore_start(): if res.returncode != 0: return jsonify({'error': f'Failed to pull from VM: {res.stderr}'}), 500 else: - # We're on VM → backup is local backup_path = f"/backups/main-server/{backup_file}" if not os.path.exists(backup_path): return jsonify({'error': f'Not found: {backup_path}'}), 400 @@ -263,9 +484,6 @@ 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 - # ── Determine the actual target ────────────────────────────────────────── - # "local" always means THIS host — wherever the platform is currently deployed. - # Run the restore script directly, no SSH indirection needed. if target == 'local': hostname = os.uname().nodename session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}" @@ -282,7 +500,6 @@ def restore_start(): ) else: - # Explicit remote machine (custom IP) if not remote_ip: return jsonify({'error': 'remote_ip required'}), 400 diff --git a/platform/modules/backups.py b/platform/modules/backups.py index 8424146..e83e864 100644 --- a/platform/modules/backups.py +++ b/platform/modules/backups.py @@ -3,6 +3,9 @@ import os import glob import subprocess import json +import hashlib +import tarfile +import re from config import ( RUNNING_ON_MAIN_SERVER, MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT, @@ -18,12 +21,21 @@ def _run(cmd, timeout=20): return '', str(e) +def _human_bytes(n): + """Human-readable byte size for audit UI.""" + n = int(n) + if n < 1024: + return f'{n} B' + if n < 1024 ** 2: + return f'{n / 1024:.1f} KB' + if n < 1024 ** 3: + return f'{n / (1024 ** 2):.1f} MB' + if n < 1024 ** 4: + return f'{n / (1024 ** 3):.2f} GB' + return f'{n / (1024 ** 4):.2f} TB' + + def _ssh_main(remote_cmd, timeout=20): - """ - Run a command ON THE MAIN SERVER. - - If we're already on the main server: run it locally. - - If we're on the VM: SSH to main server first. - """ if RUNNING_ON_MAIN_SERVER: return _run(remote_cmd, timeout=timeout) else: @@ -36,14 +48,10 @@ def _ssh_main(remote_cmd, timeout=20): # ──────────────────────────────────────────────────────────────── -# BACKUPS (local = on main server; vm = on the VM) +# BACKUPS # ──────────────────────────────────────────────────────────────── def get_local_backups(): - """ - Backups stored on the MAIN SERVER at /root/backups/. - Always fetched from main server regardless of where platform runs. - """ stdout, _ = _ssh_main( "ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null | head -20" ) @@ -57,13 +65,7 @@ def get_local_backups(): def get_vm_backups(): - """ - Backups stored on the VM at /backups/main-server/. - - On main server → SSH through tunnel (localhost:2223) - - On VM → read local directory directly - """ vm_backups = [] - if RUNNING_ON_MAIN_SERVER: try: cmd = ( @@ -81,18 +83,397 @@ def get_vm_backups(): except Exception as e: print(f"[backups] VM backup fetch error: {e}") else: - # We ARE on the VM — read directly 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 # ──────────────────────────────────────────────────────────────── -# CONTAINERS (always from main server) +# BACKUP HEALTH AUDIT +# ──────────────────────────────────────────────────────────────── + +def audit_backup(backup_file, source='local'): + """ + Perform a health and integrity audit on a backup archive. + + Checks: + 1. File exists + 2. File size sanity + 3. SHA256 checksum (if .sha256 sidecar exists) + 4. tar archive integrity (gzip test only — portable, no conflicting flags) + 5. Expected internal structure + 6. Path traversal / suspicious paths + 7. Suspicious script files at unexpected locations (scripts only, not binaries) + 8. Volume count + + Returns: + { ok, score, checks, summary } + """ + checks = [] + + def add(name, status, detail='', more=None): + entry = {'name': name, 'status': status, 'detail': detail} + if more: + entry['more'] = more + checks.append(entry) + + # ── Resolve archive path ───────────────────────────────────────────────── + if source == 'local': + archive_path = f"/root/backups/{backup_file}" + else: + archive_path = f"/backups/main-server/{backup_file}" + + # On VM auditing a "local" (main server) backup → pull to /tmp first + if not RUNNING_ON_MAIN_SERVER and source == 'local': + tmp_path = f"/tmp/audit_{backup_file}" + if not os.path.exists(tmp_path): + pull_cmd = ( + f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} " + f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 " + f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} " + f"{tmp_path}" + ) + out, err = _run(pull_cmd, timeout=120) + if not os.path.exists(tmp_path): + return { + 'ok': False, 'score': 0, + 'backup_file': backup_file, + 'file_size_bytes': None, + 'file_size_display': None, + 'health_tier': 'critical', + 'health_label': 'Unhealthy', + 'checks': [{'name': 'File Access', 'status': 'fail', + 'detail': f'Could not pull from main server: {err}'}], + 'summary': 'Cannot access backup file from this host.' + } + archive_path = tmp_path + + # ── CHECK 1: File exists ───────────────────────────────────────────────── + if not os.path.exists(archive_path): + add('File Exists', 'fail', f'Not found: {archive_path}') + return { + 'ok': False, 'score': 0, 'checks': checks, + 'backup_file': backup_file, + 'file_size_bytes': None, + 'file_size_display': None, + 'health_tier': 'critical', + 'health_label': 'Unhealthy', + 'summary': 'Backup file does not exist on disk.', + } + add('File Exists', 'pass', archive_path) + + # ── CHECK 2: File size ─────────────────────────────────────────────────── + size_bytes = os.path.getsize(archive_path) + size_mb = size_bytes / (1024 * 1024) + size_human = _human_bytes(size_bytes) + size_more = [ + f'Exact size: {size_bytes:,} bytes ({size_human})', + 'We flag archives under 1 MB as corrupt and under ~50 MB as unusually small for a full stack backup.', + ] + if size_bytes < 1024 * 1024: + add('File Size', 'fail', + f'{size_human} — suspiciously tiny, likely corrupt', more=size_more) + elif size_mb < 50: + add('File Size', 'warn', + f'{size_human} — smaller than expected (typical full backup > 50 MB)', more=size_more) + else: + add('File Size', 'pass', + f'{size_human} — within expected range', more=size_more) + + # ── CHECK 3: SHA256 checksum ───────────────────────────────────────────── + sha_file = archive_path + '.sha256' + if os.path.exists(sha_file): + try: + with open(sha_file, 'r') as f: + expected_hash = f.read().split()[0].strip() + actual_hash = _sha256_file(archive_path) + if actual_hash == expected_hash: + add('Checksum (SHA256)', 'pass', f'Hash verified — {actual_hash[:20]}…') + else: + add('Checksum (SHA256)', 'fail', + f'MISMATCH — expected {expected_hash[:20]}… got {actual_hash[:20]}…') + except Exception as e: + add('Checksum (SHA256)', 'warn', f'Could not verify: {e}') + else: + add('Checksum (SHA256)', 'warn', + 'No .sha256 sidecar found — run a new backup to get checksums') + + # ── CHECK 4: Archive integrity ─────────────────────────────────────────── + # Use gzip --test which works everywhere without conflicting tar flags + try: + result = subprocess.run( + ['gzip', '--test', archive_path], + capture_output=True, text=True, timeout=120 + ) + if result.returncode == 0: + add('Archive Integrity', 'pass', 'gzip test passed — archive is not corrupted', more=[ + 'Runs gzip --test on the .tar.gz so the compressed stream is readable end-to-end.', + ]) + else: + add('Archive Integrity', 'fail', + f'gzip test failed: {(result.stderr or result.stdout)[:200]}') + except FileNotFoundError: + # gzip not available — try python gzip + try: + import gzip + with gzip.open(archive_path, 'rb') as f: + # Read just the first few MB to check header validity + f.read(1024 * 1024) + add('Archive Integrity', 'pass', 'gzip header valid') + except Exception as e: + add('Archive Integrity', 'fail', f'Archive appears corrupt: {e}') + except subprocess.TimeoutExpired: + add('Archive Integrity', 'warn', 'Integrity check timed out — file is large, probably OK') + except Exception as e: + add('Archive Integrity', 'warn', f'Could not test: {e}') + + # ── Read archive member list (used by checks 5, 6, 7, 8) ───────────────── + members = [] + try: + with tarfile.open(archive_path, 'r:gz') as tf: + members = tf.getnames() + except Exception: + pass + + # ── CHECK 5: Internal structure ────────────────────────────────────────── + if members: + has_volumes = any('volumes/' in m for m in members) + has_info = any('backup-info.txt' in m for m in members) + has_compose = any('compose-files/' in m for m in members) + vol_count = len([m for m in members if '/volumes/' in m and m.endswith('.tar.gz')]) + + issues = [] + if not has_volumes: issues.append('volumes/ missing') + if not has_info: issues.append('backup-info.txt missing') + + if not issues: + detail = f'volumes/ ✓ backup-info.txt ✓' + if has_compose: detail += ' compose-files/ ✓' + detail += f' ({vol_count} volume archives)' + add('Internal Structure', 'pass', detail) + else: + add('Internal Structure', 'fail', ' · '.join(issues)) + else: + add('Internal Structure', 'warn', 'Could not inspect archive members') + + # ── CHECK 6: Path traversal / suspicious paths ──────────────────────────── + SUSPICIOUS = [ + (r'\.\./', 'path traversal (..)'), + (r'^/', 'absolute path in archive'), + (r'/etc/passwd', '/etc/passwd reference'), + (r'/etc/shadow', '/etc/shadow reference'), + (r'\.ssh/', '.ssh directory reference'), + (r'id_rsa(?!\.pub)', 'private SSH key reference'), + (r'authorized_keys', 'authorized_keys reference'), + ] + found_suspicious = [] + for m in members: + for pat, label in SUSPICIOUS: + if re.search(pat, m): + found_suspicious.append(f'{m} ({label})') + break + + if found_suspicious: + add('Security Scan', 'fail', + f'Suspicious entries found: {found_suspicious[:3]}') + else: + add('Security Scan', 'pass', 'No path traversal or dangerous entries detected', more=[ + 'Member paths are checked for .. segments, absolute roots, and sensitive paths ' + '(e.g. .ssh, /etc/shadow).', + ]) + + # ── CHECK 7: Suspicious scripts (smart — scripts only, not data files) ──── + # Only flag actual text script files (.sh .py .pl .rb) with execute bits + # placed outside compose-files/ and outside known vendor directories. + # .bin, .so, .exe data files are intentionally excluded (too many false positives) + SCRIPT_EXTENSIONS = ('.sh', '.py', '.pl', '.rb', '.bash', '.zsh') + SAFE_PREFIXES = ( + 'compose-files/', + 'volumes/', + 'container-configs/', + 'configs/', + ) + suspicious_scripts = [] + try: + with tarfile.open(archive_path, 'r:gz') as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + name = member.name + # Skip files in known-safe directories + if any(name.startswith(p) or f'/{p}' in name for p in SAFE_PREFIXES): + continue + # Only flag actual script extensions with execute bits + name_lower = name.lower() + has_script_ext = any(name_lower.endswith(ext) for ext in SCRIPT_EXTENSIONS) + has_exec_bit = bool(member.mode & 0o111) + if has_script_ext and has_exec_bit: + suspicious_scripts.append(os.path.basename(name)) + except Exception: + pass + + if suspicious_scripts: + add('Executable Scripts', 'warn', + f'Scripts with execute bit outside expected dirs: {suspicious_scripts[:3]}') + else: + add('Executable Scripts', 'pass', 'No unexpected executable scripts found') + + # ── CHECK 8: Volume count ──────────────────────────────────────────────── + vol_archives = [m for m in members if 'volumes/' in m and m.endswith('.tar.gz')] + v = len(vol_archives) + if v == 0: + add('Volume Count', 'fail', 'No volume archives found in backup') + elif v < 5: + add('Volume Count', 'warn', f'Only {v} volumes (expected ≥5 for a full backup)') + else: + add('Volume Count', 'pass', f'{v} volume archives present') + + # ── Score ───────────────────────────────────────────────────────────────── + weights = {'pass': 10, 'warn': 5, 'fail': 0} + total = len(checks) * 10 + earned = sum(weights.get(c['status'], 0) for c in checks) + score = int((earned / total) * 100) if total > 0 else 0 + + has_fails = any(c['status'] == 'fail' for c in checks) + ok = not has_fails and score >= 60 + + if score >= 90: + summary = 'Backup looks healthy and is safe to restore.' + elif score >= 70: + summary = 'Minor warnings — likely safe, but review before restoring.' + elif score >= 40: + summary = 'Significant issues detected — restore with caution.' + else: + summary = 'Multiple checks failed — do NOT restore without manual inspection.' + + has_warns = any(c['status'] == 'warn' for c in checks) + if has_fails: + health_tier = 'critical' + health_label = 'Unhealthy' + elif score == 100: + health_tier = 'excellent' + health_label = '100% healthy' + elif score >= 90: + health_tier = 'good' + health_label = 'Healthy' if not has_warns else 'Healthy (with notes)' + elif score >= 70: + health_tier = 'fair' + health_label = 'Mostly healthy' + elif score >= 40: + health_tier = 'poor' + health_label = 'At risk' + else: + health_tier = 'critical' + health_label = 'Unhealthy' + + return { + 'ok': ok, + 'score': score, + 'checks': checks, + 'summary': summary, + 'backup_file': backup_file, + 'file_size_bytes': size_bytes, + 'file_size_display': size_human, + 'health_tier': health_tier, + 'health_label': health_label, + } + + +def _sha256_file(path): + h = hashlib.sha256() + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + return h.hexdigest() + + +# ──────────────────────────────────────────────────────────────── +# DELETE BACKUP +# ──────────────────────────────────────────────────────────────── + +def delete_backup(backup_file, source='local'): + if not re.match(r'^myapps-backup-\d{8}_\d{6}\.tar\.gz$', backup_file): + return False, f'Invalid backup filename: {backup_file}' + + if source == 'local': + if RUNNING_ON_MAIN_SERVER: + archive_path = f"/root/backups/{backup_file}" + if not os.path.exists(archive_path): + return False, f'File not found: {archive_path}' + os.remove(archive_path) + sha = archive_path + '.sha256' + if os.path.exists(sha): os.remove(sha) + return True, f'Deleted {backup_file} from main server' + else: + cmd = f"rm -f /root/backups/{backup_file} /root/backups/{backup_file}.sha256" + out, err = _ssh_main(cmd) + if err and 'No such file' not in err: + return False, f'Remote delete error: {err}' + return True, f'Deleted {backup_file} from main server' + + elif source == 'vm': + archive_path = f"/backups/main-server/{backup_file}" + if not RUNNING_ON_MAIN_SERVER: + if not os.path.exists(archive_path): + return False, f'File not found: {archive_path}' + os.remove(archive_path) + sha = archive_path + '.sha256' + if os.path.exists(sha): os.remove(sha) + return True, f'Deleted {backup_file} from VM' + else: + cmd = ( + f"ssh -i {VM_KEY} -p {VM_PORT} " + f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " + f"{VM_USER}@{VM_HOST} " + f"'rm -f /backups/main-server/{backup_file} " + f"/backups/main-server/{backup_file}.sha256'" + ) + out, err = _run(cmd, timeout=30) + if err and 'No such file' not in err: + return False, f'VM delete error: {err}' + return True, f'Deleted {backup_file} from VM' + + return False, 'Unknown source' + + +# ──────────────────────────────────────────────────────────────── +# BACKUP STATUS LOG +# ──────────────────────────────────────────────────────────────── + +def get_backup_log_entries(limit=20): + stdout, _ = _ssh_main( + f"tail -n {limit} /root/backups/backup-status.log 2>/dev/null || echo ''" + ) + entries = [] + if not stdout: + return entries + for line in stdout.strip().split('\n'): + if not line.strip(): + continue + parts = line.split('|') + entries.append({ + 'timestamp': parts[0].strip() if len(parts) > 0 else '', + 'status': parts[1].strip() if len(parts) > 1 else '', + 'name': parts[2].strip() if len(parts) > 2 else '', + 'message': parts[3].strip() if len(parts) > 3 else '', + }) + return list(reversed(entries)) + + +def get_backup_script_path(): + candidates = ['/root/backup-myapps.sh'] + for p in candidates: + out, _ = _ssh_main(f"[ -f {p} ] && echo yes || echo no") + if out.strip() == 'yes': + return p + return None + + +# ──────────────────────────────────────────────────────────────── +# CONTAINERS # ──────────────────────────────────────────────────────────────── def _parse_containers(raw, owner='root'): @@ -113,7 +494,6 @@ def _parse_containers(raw, owner='root'): def get_containers(): - """App containers only (frappe/nextcloud/mautic/n8n/odoo) — always from main server.""" stdout, _ = _ssh_main( "docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null | " "grep -E 'frappe|nextcloud|mautic|n8n|odoo'" @@ -122,7 +502,6 @@ def get_containers(): def get_all_root_containers(): - """ALL root docker containers (unfiltered) — always from main server.""" stdout, _ = _ssh_main( "docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null" ) @@ -130,67 +509,62 @@ def get_all_root_containers(): def get_rootless_user_containers_remote(): - """ - Get containers from all rootless-docker users ON THE MAIN SERVER. - Root reads /run/user/*/docker.sock via SSH. - """ - # List all non-system UIDs that have a docker socket on main server cmd = "ls /run/user/*/docker.sock 2>/dev/null" stdout, _ = _ssh_main(cmd) containers = [] if not stdout: return containers - for sock_path in stdout.split('\n'): sock_path = sock_path.strip() if not sock_path: continue - # Determine username from uid try: uid = sock_path.split('/run/user/')[1].split('/')[0] except (IndexError, ValueError): continue - name_out, _ = _ssh_main(f"getent passwd {uid} | cut -d: -f1") username = name_out.strip() or f"uid{uid}" - ctr_out, _ = _ssh_main( f"DOCKER_HOST=unix://{sock_path} " f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null" ) containers.extend(_parse_containers(ctr_out, owner=username)) - return containers # ──────────────────────────────────────────────────────────────── -# CONTAINER ACTIONS (start / stop / restart) — on main server +# CONTAINER ACTIONS # ──────────────────────────────────────────────────────────────── def container_action(container_name, action): - """ - action: 'start' | 'stop' | 'restart' - Returns (success: bool, output: str) - """ if action not in ('start', 'stop', 'restart'): return False, "Invalid action" - safe_name = container_name.replace('"', '').replace(';', '').replace('|', '') - stdout, stderr = _ssh_main( - f"docker {action} {safe_name} 2>&1", - timeout=30 - ) + stdout, stderr = _ssh_main(f"docker {action} {safe_name} 2>&1", timeout=30) output = (stdout + stderr).strip() - success = safe_name in output or 'started' in output.lower() or stderr == '' return True, output +def get_container_status(container_name): + safe_name = container_name.replace('"', '').replace(';', '').replace('|', '') + stdout, _ = _ssh_main( + f"docker inspect --format='{{{{.State.Status}}}}' {safe_name} 2>/dev/null" + ) + raw = stdout.strip().lower() + if raw in ('running', 'restarting'): + status = 'running' + elif raw in ('exited', 'stopped', 'dead', 'paused'): + status = 'stopped' + else: + status = 'unknown' + return {'name': container_name, 'status': status, 'raw': raw} + + # ──────────────────────────────────────────────────────────────── -# STATS — from main server +# STATS # ──────────────────────────────────────────────────────────────── def get_container_stats_remote(): - """One-shot stats for all root containers on main server.""" stdout, _ = _ssh_main( "docker stats --no-stream --format " "'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null", @@ -216,10 +590,7 @@ def get_container_stats_remote(): def get_all_stats(): - """Stats for root containers on main server + rootless users on main server.""" all_stats = get_container_stats_remote() - - # Also get stats from rootless user sockets on main server socks_out, _ = _ssh_main("ls /run/user/*/docker.sock 2>/dev/null") if socks_out: for sock in socks_out.split('\n'): @@ -250,11 +621,10 @@ def get_all_stats(): # ──────────────────────────────────────────────────────────────── -# SYSTEM INFO — from main server +# SYSTEM INFO # ──────────────────────────────────────────────────────────────── def get_system_info(): - """Host-level system stats — always fetched from main server.""" cpu_out, _ = _ssh_main("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'") mem_out, _ = _ssh_main("free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}'") mem_pct, _ = _ssh_main("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'") @@ -263,8 +633,7 @@ def get_system_info(): load_out, _ = _ssh_main("cat /proc/loadavg | awk '{print $1, $2, $3}'") uptime, _ = _ssh_main("uptime -p") docker_v, _ = _ssh_main("docker --version | cut -d' ' -f3 | tr -d ','") - hostname, _ = _run("hostname -f 2>/dev/null || hostname") # THIS host, not main server - + hostname, _ = _run("hostname -f 2>/dev/null || hostname") return { 'cpu_pct': cpu_out or '0', 'memory': mem_out or 'N/A', @@ -274,5 +643,5 @@ def get_system_info(): 'load': load_out or 'N/A', 'uptime': uptime or 'N/A', 'docker_v': docker_v or 'N/A', - 'hostname': hostname or 'this server', + 'hostname': hostname or 'main server', } diff --git a/platform/static/css/style.css b/platform/static/css/style.css index 21cd990..9839985 100644 --- a/platform/static/css/style.css +++ b/platform/static/css/style.css @@ -1,9 +1,12 @@ /* ═══════════════════════════════════════════════════════════════ - Navitrends Ops Platform + Ops Platform — Server 173.249.20.244 Fonts: Syne (display) + Geist Mono (data/code) - Theme: Dark (default) / Light — toggled via [data-theme="light"] + Design ported from 83.171.248.100 platform ═══════════════════════════════════════════════════════════════ */ +@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&display=swap'); + /* ── DARK THEME (default) ──────────────────────────────────── */ :root { --bg: #0a0b0e; @@ -31,6 +34,14 @@ --font: 'Syne', sans-serif; --mono: 'Geist Mono', monospace; --trans: 0.18s ease; + + /* keep legacy var names used in 173 templates */ + --bg2: var(--surface); + --bg3: var(--surface2); + --bg4: #1e2535; + --purple-d: #6366f1; + --sidebar-w: 220px; + --sans: 'Syne', sans-serif; } /* ── LIGHT THEME ───────────────────────────────────────────── */ @@ -54,6 +65,10 @@ --cyan: #0891b2; --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06); + + --bg2: var(--surface); + --bg3: var(--surface2); + --bg4: #edf2fb; } /* ── RESET ─────────────────────────────────────────────────── */ @@ -75,26 +90,38 @@ html, body { ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; } /* ── LAYOUT ────────────────────────────────────────────────── */ -.layout { display: flex; height: 100vh; overflow: hidden; } +.layout { display: flex; min-height: 100vh; } /* ── SIDEBAR ───────────────────────────────────────────────── */ .sidebar { - width: 220px; min-width: 220px; + width: var(--sidebar-w); + min-width: var(--sidebar-w); background: var(--surface); border-right: 1px solid var(--border); - display: flex; flex-direction: column; + display: flex; + flex-direction: column; + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 100; padding: 20px 12px; transition: background 0.3s, border-color 0.3s; } -.sidebar-brand { - display: flex; align-items: center; gap: 12px; +.sidebar-brand, +a.sidebar-brand-link { + display: flex; + align-items: center; + gap: 12px; padding: 0 8px 24px; border-bottom: 1px solid var(--border); margin-bottom: 20px; + text-decoration: none; + color: inherit; } + .brand-mark { - width: 36px; height: 36px; background: var(--accent); + width: 36px; height: 36px; + background: var(--accent); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-family: var(--mono); font-weight: 500; font-size: 12px; color: #fff; @@ -103,11 +130,13 @@ html, body { .brand-name { font-size: 15px; font-weight: 700; letter-spacing: 0.02em; } .brand-sub { font-size: 10px; color: var(--text3); font-family: var(--mono); letter-spacing: 0.08em; } -.nav { flex: 1; display: flex; flex-direction: column; gap: 2px; } +/* nav */ +.nav { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; } +.nav-group-label, .nav-section-label { font-size: 9px; letter-spacing: 0.12em; color: var(--text3); font-family: var(--mono); padding: 0 8px; - margin-bottom: 6px; margin-top: 4px; + margin-bottom: 6px; margin-top: 8px; } .nav-item { display: flex; align-items: center; gap: 10px; @@ -115,22 +144,27 @@ html, body { color: var(--text2); text-decoration: none; font-size: 13px; font-weight: 500; cursor: pointer; transition: background var(--trans), color var(--trans); + margin-bottom: 1px; } +.nav-item i { width: 16px; text-align: center; font-size: 13px; } +.nav-item span { flex: 1; } .nav-item:hover { background: var(--surface2); color: var(--text); } .nav-item.active { background: var(--accent); color: #fff; } -.nav-item i { width: 16px; text-align: center; font-size: 13px; } .nav-badge { margin-left: auto; font-family: var(--mono); font-size: 10px; background: var(--border2); color: var(--text2); padding: 2px 6px; border-radius: 20px; + border: 1px solid var(--border); } -.nav-item.active .nav-badge { background: rgba(255,255,255,0.25); color: #fff; } +.nav-item.active .nav-badge { background: rgba(255,255,255,0.25); color: #fff; border-color: transparent; } +/* sidebar footer */ .sidebar-footer { display: flex; align-items: center; gap: 8px; padding-top: 16px; border-top: 1px solid var(--border); margin-top: auto; } -.server-pill { +.server-pill, +.status-pill { flex: 1; display: flex; align-items: center; gap: 7px; background: var(--surface2); border-radius: 20px; padding: 6px 10px; font-family: var(--mono); @@ -147,15 +181,18 @@ html, body { 50% { box-shadow: 0 0 0 4px rgba(34,197,94,0); } } -.logout-btn { +.logout-btn, +.icon-btn-sm { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 8px; color: var(--text3); text-decoration: none; + background: transparent; border: none; cursor: pointer; font-size: 13px; transition: background var(--trans), color var(--trans); } -.logout-btn:hover { background: var(--red); color: #fff; } +.logout-btn:hover { background: var(--red); color: #fff; } +.icon-btn-sm:hover { background: var(--surface2); color: var(--text); } -/* ── THEME TOGGLE BUTTON ───────────────────────────────────── */ +/* theme toggle */ .theme-toggle { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; @@ -167,17 +204,25 @@ html, body { .theme-toggle:hover { background: var(--border2); color: var(--text); } /* ── MAIN ──────────────────────────────────────────────────── */ -.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } +.main { + margin-left: var(--sidebar-w); + flex: 1; + display: flex; flex-direction: column; + min-height: 100vh; +} +/* ── TOPBAR ────────────────────────────────────────────────── */ .topbar { display: flex; align-items: center; justify-content: space-between; padding: 18px 28px; border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; + position: sticky; top: 0; z-index: 50; transition: background 0.3s, border-color 0.3s; } .topbar-left { display: flex; align-items: baseline; gap: 12px; } .page-title { font-size: 20px; font-weight: 700; } -.page-subtitle { font-family: var(--mono); font-size: 11px; color: var(--text3); } +.page-subtitle, +.page-sub { font-family: var(--mono); font-size: 11px; color: var(--text3); } .topbar-right { display: flex; align-items: center; gap: 10px; } .icon-btn { @@ -188,7 +233,8 @@ html, body { transition: background var(--trans), color var(--trans); font-size: 13px; } .icon-btn:hover { background: var(--border2); color: var(--text); } -.icon-btn.spinning i { animation: spin 0.8s linear infinite; } +.icon-btn.spinning i, +.icon-btn .spinning { animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .uptime-chip { @@ -197,18 +243,8 @@ html, body { padding: 5px 12px; border-radius: 20px; } -.content { - flex: 1; overflow-y: auto; padding: 24px 28px; - display: flex; flex-direction: column; gap: 20px; -} - -/* ── PAGES ─────────────────────────────────────────────────── */ -.page { display: none; flex-direction: column; gap: 20px; } -.page.active { display: flex; animation: fadeIn 0.2s ease; } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(5px); } - to { opacity: 1; transform: translateY(0); } -} +/* ── CONTENT ───────────────────────────────────────────────── */ +.content { flex: 1; padding: 24px 28px; display: flex; flex-direction: column; gap: 20px; } /* ── CARDS ─────────────────────────────────────────────────── */ .card { @@ -216,6 +252,7 @@ html, body { border-radius: var(--radius-lg); padding: 20px; box-shadow: var(--shadow); transition: background 0.3s, border-color 0.3s; + margin-bottom: 0; } .card-header { display: flex; align-items: center; justify-content: space-between; @@ -226,63 +263,114 @@ html, body { display: flex; align-items: center; gap: 8px; color: var(--text); } .card-title i { color: var(--accent2); font-size: 12px; } -.card-meta { font-family: var(--mono); font-size: 11px; color: var(--text3); } +.card-meta, +.badge-chip { + font-family: var(--mono); font-size: 11px; color: var(--text3); + background: var(--surface2); border: 1px solid var(--border); + padding: 3px 8px; border-radius: 20px; +} -/* ── SYSTEM METRICS ────────────────────────────────────────── */ -.metrics-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; } +/* ── METRICS ROW ───────────────────────────────────────────── */ +.metrics-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; +} .metric-card { background: var(--surface2); border: 1px solid var(--border); - border-radius: var(--radius); padding: 16px; position: relative; overflow: hidden; + border-radius: var(--radius); padding: 16px 18px; + position: relative; overflow: hidden; transition: background 0.3s, border-color 0.3s; } .metric-card::before { - content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--accent); + content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; + background: var(--accent); } -.metric-card.mem::before { background: var(--purple); } -.metric-card.disk::before { background: var(--cyan); } -.metric-card.load::before { background: var(--yellow); } +.metric-card:nth-child(2)::before { background: var(--purple); } +.metric-card:nth-child(3)::before { background: var(--cyan); } +.metric-card:nth-child(4)::before { background: var(--yellow); } -.metric-label { font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em; color: var(--text3); margin-bottom: 8px; } -.metric-value { font-family: var(--mono); font-size: 22px; font-weight: 500; color: var(--text); line-height: 1; margin-bottom: 10px; } -.metric-value span { font-size: 12px; color: var(--text3); } - -.gauge-bar { height: 3px; background: var(--border2); border-radius: 4px; overflow: hidden; } -.gauge-fill { - height: 100%; border-radius: 4px; background: var(--accent); - transition: width 0.6s ease; +.metric-label { + font-family: var(--mono); font-size: 9px; font-weight: 700; + letter-spacing: 0.12em; color: var(--text3); margin-bottom: 8px; } -.metric-card.mem .gauge-fill { background: var(--purple); } -.metric-card.disk .gauge-fill { background: var(--cyan); } -.metric-card.load .gauge-fill { background: var(--yellow); } +.metric-value { + font-family: var(--mono); font-size: 26px; font-weight: 700; + letter-spacing: -0.02em; color: var(--text); + line-height: 1; margin-bottom: 10px; +} +.metric-value.small { font-size: 16px; } +.metric-unit { font-size: 13px; color: var(--text3); font-weight: 400; } + +/* unified gauge names (83 uses gauge-bar/gauge-fill; 173 uses gauge-track/gauge-fill) */ +.gauge-bar, +.gauge-track { height: 3px; background: var(--border2); border-radius: 4px; overflow: hidden; } +.gauge-fill { + height: 100%; border-radius: 4px; + background: var(--accent); + transition: width 0.6s ease; max-width: 100%; +} +.metric-card:nth-child(2) .gauge-fill { background: var(--purple); } +.metric-card:nth-child(3) .gauge-fill { background: var(--cyan); } +.metric-card:nth-child(4) .gauge-fill { background: var(--yellow); } /* ── STAT ROW ──────────────────────────────────────────────── */ -.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; } -.stat-card { +/* 173 uses stat-box; 83 uses stat-card — support both */ +.stat-row { display: flex; gap: 0; } + +.stat-box { + flex: 1; text-align: center; + padding: 16px 8px; + border-right: 1px solid var(--border); +} +.stat-box:last-child { border-right: none; } +.stat-num { + font-size: 28px; font-weight: 700; + font-family: var(--mono); letter-spacing: -0.02em; margin-bottom: 4px; + color: var(--text); +} +.stat-num.green { color: var(--green); } +.stat-num.cyan { color: var(--cyan); } +.stat-num.dim { color: var(--text3); font-size: 14px; } +.stat-lbl { font-size: 10px; color: var(--text3); letter-spacing: 0.06em; } + +.stat-row .stat-card { + flex: 1; text-align: center; background: var(--surface2); border: 1px solid var(--border); - border-radius: var(--radius); padding: 14px 16px; text-align: center; - transition: background 0.3s; + border-radius: var(--radius); padding: 14px 16px; } .stat-number { font-family: var(--mono); font-size: 28px; font-weight: 500; color: var(--accent2); line-height: 1; } .stat-label { font-size: 11px; color: var(--text3); margin-top: 5px; letter-spacing: 0.04em; } -/* ── CONTAINERS TABLE ──────────────────────────────────────── */ -.ct-table { width: 100%; border-collapse: collapse; } +/* ── TABLES ────────────────────────────────────────────────── */ +.table-wrap, +.ct-table-wrap { overflow-x: auto; } + +.data-table, +.ct-table { width: 100%; border-collapse: collapse; font-size: 13px; } + +.data-table th, .ct-table th { - font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em; - color: var(--text3); padding: 6px 10px; text-align: left; + text-align: left; + font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: 0.1em; + color: var(--text3); padding: 6px 12px 10px; border-bottom: 1px solid var(--border); } +.data-table td, .ct-table td { - padding: 10px; border-bottom: 1px solid var(--border); - font-size: 13px; vertical-align: middle; + padding: 10px 12px; border-bottom: 1px solid var(--border); + vertical-align: middle; } +.data-table tr:last-child td, .ct-table tr:last-child td { border-bottom: none; } +.data-table tr:hover td, .ct-table tr:hover td { background: var(--surface2); } -.ct-name { font-family: var(--mono); font-size: 13px; color: var(--text); font-weight: 500; } -.ct-image { font-family: var(--mono); font-size: 11px; color: var(--text3); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ct-ports { font-family: var(--mono); font-size: 11px; color: var(--cyan); } -.ct-owner { font-family: var(--mono); font-size: 11px; color: var(--purple); } +.ct-name { font-family: var(--mono); font-weight: 500; font-size: 12px; color: var(--text); } +.ct-image { font-family: var(--mono); font-size: 11px; color: var(--text3); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ct-ports { font-family: var(--mono); font-size: 11px; color: var(--cyan); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mono { font-family: var(--mono); } +.dim { color: var(--text3); font-size: 12px; } /* ── BADGES ────────────────────────────────────────────────── */ .badge { @@ -290,80 +378,94 @@ html, body { padding: 3px 9px; border-radius: 20px; font-family: var(--mono); font-size: 11px; font-weight: 500; } -.badge-run { background: rgba(34,197,94,0.12); color: var(--green); } -.badge-stop { background: rgba(239,68,68,0.12); color: var(--red); } -.badge-run::before { content:''; width:5px; height:5px; background:var(--green); border-radius:50%; } -.badge-stop::before { content:''; width:5px; height:5px; background:var(--red); border-radius:50%; } -[data-theme="light"] .badge-run { background: rgba(22,163,74,0.1); } -[data-theme="light"] .badge-stop { background: rgba(220,38,38,0.1); } +/* support both naming conventions */ +.badge-run, .badge.run { background: rgba(34,197,94,0.12); color: var(--green); border: 1px solid rgba(34,197,94,0.2); } +.badge-stop, .badge.stop { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.2); } +.badge-run::before, .badge.run::before { content:''; width:5px; height:5px; background:var(--green); border-radius:50%; } +.badge-stop::before, .badge.stop::before { content:''; width:5px; height:5px; background:var(--red); border-radius:50%; } +[data-theme="light"] .badge-run, [data-theme="light"] .badge.run { background: rgba(22,163,74,0.1); } +[data-theme="light"] .badge-stop, [data-theme="light"] .badge.stop { background: rgba(220,38,38,0.1); } /* ── INLINE STAT BARS ──────────────────────────────────────── */ -.stat-bar-wrap { display: flex; align-items: center; gap: 6px; min-width: 90px; } -.stat-bar-bg { flex: 1; height: 4px; background: var(--border2); border-radius: 4px; overflow: hidden; } -.stat-bar-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width 0.5s ease; } +/* 173 uses bar-wrap/bar-bg/bar-fill; 83 uses stat-bar-wrap etc — support both */ +.stat-bar-wrap, +.bar-wrap { display: flex; align-items: center; gap: 6px; min-width: 90px; } +.stat-bar-bg, +.bar-bg { flex: 1; height: 4px; background: var(--border2); border-radius: 4px; overflow: hidden; } +.stat-bar-fill, +.bar-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width 0.5s ease; } +.bar-fill.warn, .stat-bar-fill.warn { background: var(--yellow); } +.bar-fill.crit, .stat-bar-fill.crit { background: var(--red); } -.stat-pct { font-family: var(--mono); font-size: 10px; color: var(--text3); min-width: 36px; } +.stat-pct, +.stat-val { font-family: var(--mono); font-size: 11px; color: var(--text3); min-width: 36px; } +.stat-val.cyan { color: var(--cyan); } -/* ── USERS GRID ────────────────────────────────────────────── */ -.users-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; } -.user-card { - background: var(--surface2); border: 1px solid var(--border); - border-radius: var(--radius); padding: 16px; cursor: pointer; - transition: border-color var(--trans), background var(--trans), box-shadow var(--trans); -} -.user-card:hover { border-color: var(--accent2); box-shadow: 0 0 0 1px var(--accent2); } -.user-card-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } -.user-avatar { - width: 38px; height: 38px; border-radius: 50%; display: flex; - align-items: center; justify-content: center; - font-weight: 700; font-size: 14px; flex-shrink: 0; color: #fff; -} -.user-name { font-weight: 600; font-size: 14px; } -.user-uid { font-family: var(--mono); font-size: 11px; color: var(--text3); } -.user-tags { display: flex; gap: 6px; flex-wrap: wrap; } -.user-tag { - font-family: var(--mono); font-size: 10px; - padding: 2px 8px; border-radius: 20px; - background: var(--border2); color: var(--text2); -} -.user-tag.docker { background: rgba(59,130,246,0.15); color: var(--accent2); } -.user-tag.linger { background: rgba(34,197,94,0.12); color: var(--green); } -[data-theme="light"] .user-tag.docker { background: rgba(37,99,235,0.1); } -[data-theme="light"] .user-tag.linger { background: rgba(22,163,74,0.1); } +/* ── ACTION BUTTONS ────────────────────────────────────────── */ +/* unified: 83 uses ctr-action-btn; 173 uses act-btn — both work */ +.action-btns { display: flex; gap: 4px; align-items: center; } -.user-stats { display: flex; gap: 16px; margin-top: 10px; } -.user-stat { font-family: var(--mono); font-size: 11px; color: var(--text3); } -.user-stat strong { color: var(--text); } +.ctr-action-btn, +.act-btn { + width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + border-radius: 7px; border: none; cursor: pointer; font-size: 11px; + transition: background 0.15s, transform 0.1s, opacity 0.15s; +} +.ctr-action-btn:hover, +.act-btn:hover { transform: scale(1.12); } +.ctr-action-btn:disabled, +.act-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } + +.ctr-action-btn.restart, +.act-btn.restart { background: rgba(59,130,246,0.15); color: var(--accent2); } +.ctr-action-btn.restart:hover, +.act-btn.restart:hover { background: rgba(59,130,246,0.3); } + +.ctr-action-btn.stop, +.act-btn.stop { background: rgba(239,68,68,0.12); color: var(--red); } +.ctr-action-btn.stop:hover, +.act-btn.stop:hover { background: rgba(239,68,68,0.25); } + +.ctr-action-btn.start, +.act-btn.start { background: rgba(34,197,94,0.12); color: var(--green); } +.ctr-action-btn.start:hover, +.act-btn.start:hover { background: rgba(34,197,94,0.25); } + +/* ── BUTTONS ───────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: 7px; + padding: 9px 16px; border-radius: 8px; + font-size: 13px; font-weight: 600; font-family: var(--font); + cursor: pointer; border: none; text-decoration: none; + transition: opacity var(--trans), filter var(--trans), transform 0.1s; +} +.btn:hover { filter: brightness(1.1); transform: translateY(-1px); } +.btn:active { transform: translateY(0); } +.btn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; transform: none; } + +.btn-primary, .btn.primary { background: var(--accent); color: #fff; } +.btn-danger, .btn.danger { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.3); } +.btn-success { background: var(--green); color: #fff; } +.btn-ghost, .btn.ghost { background: var(--surface2); border: 1px solid var(--border2); color: var(--text2); } +.btn-ghost:hover, .btn.ghost:hover { background: var(--border2); color: var(--text); filter: none; } +.btn-sm, .btn.sm { padding: 5px 11px; font-size: 12px; } +.btn-lg, .btn.lg { padding: 12px 24px; font-size: 14px; } /* ── RESTORE FORM ──────────────────────────────────────────── */ -.restore-form { display: flex; flex-direction: column; gap: 0; } -.form-section { padding: 20px 0; border-bottom: 1px solid var(--border); } +.restore-form { max-width: 700px; display: flex; flex-direction: column; gap: 0; } +.form-section { padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 0; } .form-section:last-child { border-bottom: none; } -.form-section-title { font-size: 10px; font-family: var(--mono); letter-spacing: 0.12em; color: var(--text3); margin-bottom: 14px; } - -.radio-group { display: flex; gap: 10px; flex-wrap: wrap; } -.radio-card { cursor: pointer; flex: 1; min-width: 180px; } -.radio-card input { display: none; } -.radio-body { - display: flex; align-items: center; gap: 10px; - padding: 12px 14px; border: 1px solid var(--border2); - border-radius: var(--radius); background: var(--surface2); - transition: border-color var(--trans), background var(--trans); +.form-section-title { + font-size: 9px; font-family: var(--mono); font-weight: 700; + letter-spacing: 0.12em; color: var(--text3); margin-bottom: 14px; } -.radio-card input:checked + .radio-body { border-color: var(--accent); background: rgba(59,130,246,0.08); } -[data-theme="light"] .radio-card input:checked + .radio-body { background: rgba(37,99,235,0.06); } -.radio-label { font-size: 13px; font-weight: 600; } -.radio-desc { font-size: 11px; color: var(--text3); } -.radio-desc code { background: var(--border2); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); } -.radio-icon { font-size: 18px; } -.radio-card.small .radio-body { padding: 9px 12px; gap: 8px; } -.radio-card.small .radio-label { font-size: 12px; } - -.form-row { display: flex; gap: 12px; } -.form-row .form-group { flex: 1; } .form-group { display: flex; flex-direction: column; gap: 6px; } -.form-label { font-size: 11px; font-weight: 600; color: var(--text2); letter-spacing: 0.06em; } +.form-label { + font-size: 11px; font-weight: 600; color: var(--text2); + letter-spacing: 0.06em; +} .form-input { background: var(--surface2); border: 1px solid var(--border2); border-radius: 8px; padding: 9px 12px; font-size: 13px; @@ -372,36 +474,45 @@ html, body { } .form-input:focus { outline: none; border-color: var(--accent); } .form-input[readonly] { opacity: 0.6; cursor: default; } +.form-row { display: flex; gap: 12px; } +.form-row .form-group { flex: 1; } +.form-note { font-size: 12px; color: var(--text3); margin-top: 8px; } .form-check { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; } .form-check input { accent-color: var(--accent); width: 14px; height: 14px; } -/* ── BUTTONS ───────────────────────────────────────────────── */ -.btn { - display: inline-flex; align-items: center; gap: 7px; - padding: 9px 16px; border-radius: 8px; - font-size: 13px; font-weight: 600; font-family: var(--font); - cursor: pointer; border: none; - transition: opacity var(--trans), background var(--trans), filter var(--trans); +.radio-group { display: flex; gap: 10px; flex-wrap: wrap; } +.radio-card { cursor: pointer; flex: 1; min-width: 160px; } +.radio-card.sm { flex: unset; } +.radio-card input { display: none; } +.radio-body { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; border: 1px solid var(--border2); + border-radius: var(--radius); background: var(--surface2); + transition: border-color var(--trans), background var(--trans); } -.btn:hover { filter: brightness(1.1); } -.btn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } -.btn-primary { background: var(--accent); color: #fff; } -.btn-danger { background: var(--red); color: #fff; } -.btn-success { background: var(--green); color: #fff; } -.btn-ghost { background: var(--surface2); border: 1px solid var(--border2); color: var(--text2); } -.btn-ghost:hover { background: var(--border2); color: var(--text); filter: none; } -.btn-sm { padding: 5px 11px; font-size: 12px; } -.btn-lg { padding: 12px 24px; font-size: 14px; } +.radio-card.sm .radio-body { padding: 9px 12px; min-width: 110px; } +.radio-card input:checked + .radio-body { border-color: var(--accent); background: rgba(59,130,246,0.08); } +[data-theme="light"] .radio-card input:checked + .radio-body { background: rgba(37,99,235,0.06); } +.radio-icon { font-size: 16px; color: var(--accent2); } +.radio-label { font-size: 13px; font-weight: 600; } +.radio-desc { font-size: 11px; color: var(--text3); font-family: var(--mono); margin-top: 2px; } +.radio-desc code { background: var(--border2); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); } /* ── LOG CONSOLE ───────────────────────────────────────────── */ +.log-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; font-size: 12px; font-weight: 600; color: var(--text2); +} .log-console { background: #05060a; border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; font-family: var(--mono); font-size: 12px; line-height: 1.7; max-height: 400px; overflow-y: auto; color: #8892a4; + white-space: pre-wrap; word-break: break-all; } [data-theme="light"] .log-console { background: #1a1f2e; border-color: #2d3555; } -.log-line { white-space: pre-wrap; word-break: break-all; } +.log-line { margin: 0; } +.log-elapsed { font-size: 11px; font-family: var(--mono); color: var(--text3); margin-top: 6px; } /* ── BACKUP LIST ───────────────────────────────────────────── */ .backup-list { display: flex; flex-direction: column; gap: 8px; } @@ -411,28 +522,101 @@ html, body { background: var(--surface2); gap: 10px; flex-wrap: wrap; border: 1px solid var(--border); } -.backup-name { font-family: var(--mono); font-size: 12px; color: var(--text2); } +.backup-name { font-family: var(--mono); font-size: 12px; color: var(--text2); } +.backup-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } +.btn-delete-backup { color: var(--red) !important; opacity: 0.7; transition: opacity 0.15s; } +.btn-delete-backup:hover { opacity: 1; } +.btn-audit { color: var(--cyan) !important; opacity: 0.85; } +.btn-audit:hover { opacity: 1; } -/* ── TWO COLUMN ────────────────────────────────────────────── */ -.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -@media (max-width: 768px) { .two-col { grid-template-columns: 1fr; } } +/* ── MODAL ─────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; inset: 0; z-index: 1000; + background: rgba(0,0,0,0.65); + backdrop-filter: blur(3px); + display: flex; align-items: center; justify-content: center; + padding: 20px; animation: fadeIn 0.15s ease; +} +.modal-box { + background: #111318; border: 1px solid #1e2330; + border-radius: 16px; width: 100%; max-width: 600px; + max-height: 88vh; overflow-y: auto; padding: 24px; + box-shadow: 0 32px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04); + position: relative; +} +[data-theme="light"] .modal-box { background: #fff; border-color: #dde1ed; box-shadow: 0 20px 60px rgba(0,0,0,0.15); } +.modal-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid #1e2330; +} +[data-theme="light"] .modal-header { border-bottom-color: #dde1ed; } +.modal-title { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; color: #e8ecf4; } +[data-theme="light"] .modal-title { color: #0f1117; } +.modal-close { + background: none; border: none; cursor: pointer; color: #4a5568; + font-size: 18px; line-height: 1; padding: 6px 10px; border-radius: 8px; + transition: background 0.15s, color 0.15s; +} +.modal-close:hover { background: rgba(239,68,68,0.12); color: #ef4444; } +.audit-footer { + display: flex; gap: 8px; margin-top: 18px; padding-top: 14px; + border-top: 1px solid #1e2330; +} +[data-theme="light"] .audit-footer { border-top-color: #dde1ed; } -/* ── FORM GRID ─────────────────────────────────────────────── */ -.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } -@media (max-width: 600px) { .form-grid { grid-template-columns: 1fr; } } - -/* ── SECTION HEADER ────────────────────────────────────────── */ -.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } -.section-title { font-size: 13px; font-weight: 600; color: var(--text2); font-family: var(--mono); letter-spacing: 0.06em; } +/* ── NOTICE ────────────────────────────────────────────────── */ +.notice { + padding: 12px 16px; border-radius: var(--radius); + font-size: 13px; display: flex; align-items: flex-start; gap: 10px; +} +.notice.warning { + background: rgba(245,158,11,0.08); border: 1px solid rgba(245,158,11,0.2); + color: var(--yellow); +} +.notice code { font-family: var(--mono); font-size: 12px; background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 4px; } /* ── ALERTS ────────────────────────────────────────────────── */ .alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; display: none; } -.alert.show { display: block; } -.alert-success { background: rgba(34,197,94,0.1); color: var(--green); border: 1px solid rgba(34,197,94,0.2); } -.alert-error { background: rgba(239,68,68,0.1); color: var(--red); border: 1px solid rgba(239,68,68,0.2); } -[data-theme="light"] .alert-success { background: rgba(22,163,74,0.08); } -[data-theme="light"] .alert-error { background: rgba(220,38,38,0.08); } +.alert.show { display: block; } +.alert-success { background: rgba(34,197,94,0.1); color: var(--green); border: 1px solid rgba(34,197,94,0.2); } +.alert-error { background: rgba(239,68,68,0.1); color: var(--red); border: 1px solid rgba(239,68,68,0.2); } /* ── EMPTY STATE ───────────────────────────────────────────── */ -.empty-state { text-align: center; padding: 36px; color: var(--text3); font-size: 13px; } +.empty-state, +.empty { + text-align: center; padding: 36px; color: var(--text3); + font-size: 13px; display: flex; align-items: center; justify-content: center; gap: 8px; +} .empty-state i { font-size: 26px; display: block; margin-bottom: 10px; opacity: 0.35; } +.empty.warn { color: var(--yellow); } + +/* ── SETTINGS ──────────────────────────────────────────────── */ +.settings-grid { display: flex; flex-direction: column; gap: 0; } +.settings-row { + display: flex; justify-content: space-between; align-items: flex-start; + gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border); +} +.settings-row:last-child { border-bottom: none; } +.settings-label { + font-size: 11px; font-weight: 600; letter-spacing: 0.08em; + color: var(--text3); text-transform: uppercase; flex: 0 0 180px; +} +.settings-value { font-size: 14px; color: var(--text2); text-align: right; flex: 1; line-height: 1.45; } + +/* ── TWO COL ───────────────────────────────────────────────── */ +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +@media (max-width: 768px) { .two-col { grid-template-columns: 1fr; } } + +/* ── UTILITIES ─────────────────────────────────────────────── */ +.row-gap { display: flex; align-items: center; gap: 8px; } +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } +.section-title { font-size: 13px; font-weight: 600; color: var(--text2); font-family: var(--mono); letter-spacing: 0.06em; } + +/* ── ANIMATION ─────────────────────────────────────────────── */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +/* manual backup wrapper */ +#manual-backup-wrapper { border-top: 1px solid var(--border); padding-top: 16px; } \ No newline at end of file diff --git a/platform/static/js/platform.js b/platform/static/js/platform.js new file mode 100644 index 0000000..a4355fa --- /dev/null +++ b/platform/static/js/platform.js @@ -0,0 +1,634 @@ +const extraState = { app: false, all: false }; +let selectedUser = null; +let manualBackupJobId = null; +let manualBackupPoll = null; +let currentJobId = null; +let pollInterval = null; + +function el(id) { return document.getElementById(id); } + +function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function auditScoreBlockTierClass(tier) { + const t = String(tier || 'fair'); + if (t === 'excellent' || t === 'good') return 'audit-tier-good'; + if (t === 'fair') return 'audit-tier-fair'; + if (t === 'poor') return 'audit-tier-poor'; + return 'audit-tier-critical'; +} + +function renderAuditModalContent(d, filename) { + if (d.error) { + return `
${escapeHtml(d.error)}
+`; + } + + const tierCls = auditScoreBlockTierClass(d.health_tier); + const score = Math.max(0, Math.min(100, Number(d.score) || 0)); + const fileSize = d.file_size_display + ? escapeHtml(d.file_size_display) + : (d.file_size_bytes != null ? escapeHtml(String(d.file_size_bytes)) + ' B' : '—'); + const fname = escapeHtml(d.backup_file || filename); + const summary = escapeHtml(d.summary || ''); + const healthLabel = escapeHtml(d.health_label || ''); + const badgeSafe = !!d.ok; + const badgeText = badgeSafe ? '✓ Safe to restore' : '✗ Review before restore'; + const badgeClass = badgeSafe ? 'audit-health-badge audit-health-badge-safe' : 'audit-health-badge audit-health-badge-unsafe'; + + const checksHtml = (d.checks || []).map((c) => { + const st = c.status === 'fail' ? 'fail' : c.status === 'warn' ? 'warn' : 'pass'; + const icon = st === 'pass' ? '✓' : st === 'warn' ? '⚠' : '✗'; + const iconCls = `audit-check-icon audit-check-icon-${st}`; + const detail = escapeHtml(c.detail || ''); + const moreLines = Array.isArray(c.more) ? c.more : []; + const moreBlock = moreLines.length + ? `
    ${moreLines.map((line) => `
  • ${escapeHtml(line)}
  • `).join('')}
` + : ''; + + return `
+ +${icon} +${escapeHtml(c.name)} +${st} + + +
+

${detail}

+${moreBlock} +
+
`; + }).join(''); + + return `
File
+${fname} +
Size${fileSize}
+
+
+
${score}
+
Score
+
+
+
+${healthLabel} +${badgeText} +
+

${summary}

+
+
+
+

Expand a row to see exactly what was verified (size, checksum, archive test, paths, …).

+
${checksHtml}
+`; +} + +function quickRestoreFromAuditModal() { + const modal = el('audit-modal'); + if (!modal) return; + const s = modal.dataset.auditSource || ''; + const f = modal.dataset.auditFile || ''; + modal.style.display = 'none'; + if (s && f) quickRestore(s, f); +} + +function setText(id, value) { + const node = el(id); + if (node) node.textContent = value; +} + +function toggleExtraColumns(prefix) { + extraState[prefix] = !extraState[prefix]; + const show = extraState[prefix]; + document.querySelectorAll(`.${prefix}-extra`).forEach((node) => { + node.style.display = show ? "" : "none"; + }); + const btn = el(`${prefix}-toggle-btn`); + if (btn) { + btn.innerHTML = show + ? ' Show less' + : ' Show more'; + } +} + +/** Sidebar counts (present in base.html on every page). */ +async function refreshSidebarNavBadges() { + const setBadges = (containerCount, userCount) => { + const c = el('nav-badge-containers'); + const u = el('nav-badge-users'); + if (c) c.textContent = containerCount != null ? containerCount : '—'; + if (u) u.textContent = userCount != null ? userCount : '—'; + }; + try { + const r = await fetch('/api/nav-summary', { credentials: 'same-origin' }); + const d = r.ok ? await r.json() : null; + const cc = d && Number.isFinite(Number(d.container_count)) ? Number(d.container_count) : null; + const uc = d && Number.isFinite(Number(d.user_count)) ? Number(d.user_count) : null; + setBadges(cc, uc); + } catch (_) { + setBadges(null, null); + } +} + +async function checkServerStatus() { + try { + const r = await fetch('/server/status'); + const d = await r.json(); + const dot = el('pulse-dot'); + const text = el('server-status-text'); + if (!dot || !text) return; + if (d.status === 'online') { + dot.className = 'pulse-dot online'; + text.textContent = 'Online'; + } else { + dot.className = 'pulse-dot offline'; + text.textContent = 'Offline'; + } + } catch (_) {} +} + +async function refreshSystemMetrics() { + try { + const r = await fetch('/api/system'); + const d = await r.json(); + if (el('m-cpu')) el('m-cpu').innerHTML = d.cpu_pct + '%'; + setText('m-mem', d.memory); + setText('m-disk', d.disk); + setText('m-load', d.load); + if (el('g-cpu')) el('g-cpu').style.width = Math.min(parseFloat(d.cpu_pct) || 0, 100) + '%'; + if (el('g-mem')) el('g-mem').style.width = Math.min(parseFloat(d.mem_pct) || 0, 100) + '%'; + if (el('g-disk')) el('g-disk').style.width = Math.min(parseFloat(d.disk_pct) || 0, 100) + '%'; + setText('uptime-chip', d.uptime); + if (el('settings-uptime')) el('settings-uptime').value = d.uptime; + if (d.hostname && el('this-server-desc')) el('this-server-desc').textContent = d.hostname; + } catch (_) {} +} + +async function refreshContainerStats() { + try { + const r = await fetch('/api/stats'); + const stats = await r.json(); + document.querySelectorAll('[data-stat]').forEach((node) => { + const name = node.dataset.ctr; + const stat = node.dataset.stat; + const s = stats[name]; + if (!s) return; + if (stat === 'cpu') node.textContent = s.cpu || '—'; + if (stat === 'net') node.textContent = s.net || '—'; + if (stat === 'block') node.textContent = s.block || '—'; + if (stat === 'mem_pct') node.textContent = s.mem_pct || '—'; + if (stat === 'mem_bar') { + const pct = parseFloat(s.mem_pct) || 0; + node.style.width = Math.min(pct, 100) + '%'; + node.className = 'stat-bar-fill' + (pct > 85 ? ' crit' : pct > 65 ? ' warn' : ''); + } + }); + } catch (_) {} +} + +function statusBadgeHTML(status) { + if (status === 'running') return 'Running'; + if (status === 'stopped') return 'Stopped'; + return 'Unknown'; +} + +function updateContainerStatusBadge(name, status) { + document.querySelectorAll(`.ctr-status-cell[data-ctr="${name}"]`).forEach((cell) => { + cell.innerHTML = statusBadgeHTML(status); + }); + document.querySelectorAll('#all-containers-body tr').forEach((row) => { + const nameTd = row.querySelector('.ct-name'); + if (nameTd && nameTd.textContent.trim() === name) { + const statusTd = row.cells[2]; + if (statusTd) statusTd.innerHTML = statusBadgeHTML(status); + } + }); +} + +async function ctrAction(name, action, btn) { + const origHTML = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ''; + try { + const r = await fetch('/api/container/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, action }) + }); + const d = await r.json(); + if (d.new_status) updateContainerStatusBadge(name, d.new_status); + btn.innerHTML = d.success ? '' : ''; + setTimeout(() => { + btn.innerHTML = origHTML; + btn.disabled = false; + setTimeout(() => { + refreshContainerStats(); + if (el('all-containers-body')) loadAllContainers(); + }, 1500); + }, 1200); + } catch (_) { + btn.innerHTML = origHTML; + btn.disabled = false; + } +} + +function buildActionBtns(name) { + return `
+ + + +
`; +} + +async function loadAllContainers() { + const body = el('all-containers-body'); + if (!body) return; + const meta = el('all-ctr-meta'); + body.innerHTML = '
Loading…
'; + try { + const [ctrRes, statRes] = await Promise.all([fetch('/api/containers/all'), fetch('/api/stats')]); + const { containers, running } = await ctrRes.json(); + const stats = await statRes.json(); + if (meta) meta.textContent = `${containers.length} total · ${running} running`; + if (el('nav-badge-containers')) el('nav-badge-containers').textContent = containers.length; + if (!containers.length) { + body.innerHTML = '
No containers
'; + return; + } + const showExtra = extraState.all; + body.innerHTML = containers.map((c) => { + const up = c.status.includes('Up'); + const s = stats[c.name] || {}; + const pct = parseFloat(s.mem_pct) || 0; + const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : ''; + const ed = showExtra ? '' : 'display:none;'; + return ` + ${c.name}${c.owner} + ${up ? 'Running' : 'Stopped'} + ${s.cpu || '—'} +
${s.mem_pct || '—'}
+ ${s.net || '—'} + ${s.block || '—'} + ${c.image} + ${c.ports || '—'} + ${buildActionBtns(c.name)}`; + }).join(''); + } catch (e) { + body.innerHTML = `
${e}
`; + } +} + +async function refreshBackupsList() { + try { + const r = await fetch('/api/backups'); + const d = await r.json(); + renderBackupList(d.local, 'local-backup-list', 'local'); + renderBackupList(d.vm, 'vm-backup-list', 'vm'); + if (el('stat-local-bk')) el('stat-local-bk').textContent = d.local.length; + if (el('stat-vm-bk')) el('stat-vm-bk').textContent = d.vm.length; + if (el('local-options')) el('local-options').innerHTML = d.local.length ? d.local.map((b) => ``).join('') : ''; + if (el('vm-options')) el('vm-options').innerHTML = d.vm.length ? d.vm.map((b) => ``).join('') : ''; + } catch (_) {} +} + +function renderBackupList(items, id, source) { + const node = el(id); + if (!node) return; + if (!items || !items.length) { + node.innerHTML = '
No backups
'; + return; + } + node.innerHTML = items.map((b) => ` +
+ ${b} +
+ + + +
+
`).join(''); +} + +async function auditBackup(source, filename, btn) { + const modal = el('audit-modal'); + const content = el('audit-modal-content'); + if (!modal || !content) return; + modal.dataset.auditSource = source; + modal.dataset.auditFile = filename; + modal.style.display = 'flex'; + content.innerHTML = '
Running audit…
'; + if (btn) { btn.disabled = true; btn.innerHTML = ''; } + try { + const r = await fetch('/api/backups/audit', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backup_file: filename, source }) + }); + const d = await r.json(); + if (!r.ok) { + content.innerHTML = renderAuditModalContent({ error: d.error || d.message || `HTTP ${r.status}` }, filename); + return; + } + content.innerHTML = renderAuditModalContent(d, filename); + } catch (e) { + content.innerHTML = `
Audit failed: ${escapeHtml(e)}
+`; + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' Audit'; } + } +} + +function closeAuditModal(e) { + const modal = el('audit-modal'); + if (!modal) return; + if (!e || e.target === modal) modal.style.display = 'none'; +} + +async function deleteBackup(source, filename, btn) { + if (!confirm(`Delete backup:\n${filename}\n\nThis cannot be undone.`)) return; + if (btn) { btn.disabled = true; btn.innerHTML = ''; } + try { + const r = await fetch('/api/backups/delete', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backup_file: filename, source }) + }); + const d = await r.json(); + if (!d.success) alert(`Delete failed: ${d.message}`); + refreshBackupsList(); + } catch (e) { + alert(`Error: ${e}`); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ''; } + } +} + +async function runManualBackup() { + const btn = el('manual-backup-btn'); + if (!btn) return; + btn.disabled = true; + btn.innerHTML = ' Starting…'; + const wrapper = el('manual-backup-wrapper'); + const logEl = el('manual-backup-log'); + if (wrapper) wrapper.style.display = ''; + if (logEl) logEl.innerHTML = ''; + try { + const r = await fetch('/api/backups/run', { method: 'POST' }); + const d = await r.json(); + if (!d.success) throw new Error(d.message || 'Failed'); + manualBackupJobId = d.job_id; + pollManualBackup(); + } catch (e) { + if (logEl) logEl.textContent = `❌ ${e}`; + btn.disabled = false; + btn.innerHTML = ' Run Backup Now'; + } +} + +function pollManualBackup() { + if (manualBackupPoll) clearInterval(manualBackupPoll); + let lastLine = 0; + manualBackupPoll = setInterval(async () => { + if (!manualBackupJobId) return; + try { + const r = await fetch(`/api/backups/run/status/${manualBackupJobId}`); + const d = await r.json(); + d.log.slice(lastLine).forEach(appendBackupLog); + lastLine = d.log.length; + if (el('manual-backup-elapsed')) el('manual-backup-elapsed').textContent = `⏱ ${d.elapsed}s`; + if (d.status !== 'running') { + clearInterval(manualBackupPoll); + if (el('manual-backup-btn')) { + el('manual-backup-btn').disabled = false; + el('manual-backup-btn').innerHTML = ' Run Backup Now'; + } + if (d.status === 'done') setTimeout(refreshBackupsList, 1200); + } + } catch (_) {} + }, 1500); +} + +function appendBackupLog(line) { + const logEl = el('manual-backup-log'); + if (!logEl) return; + const div = document.createElement('div'); + div.className = 'log-line'; + div.textContent = line; + logEl.appendChild(div); + logEl.scrollTop = logEl.scrollHeight; +} + +async function loadBackupLog() { + const node = el('backup-history-list'); + if (!node) return; + node.innerHTML = '
Loading…
'; + try { + const r = await fetch('/api/backups/log?limit=15'); + const d = await r.json(); + if (!d.entries || !d.entries.length) { + node.innerHTML = '
No backup history yet
'; + return; + } + node.innerHTML = d.entries.map((e) => `
${e.name}${e.status}
`).join(''); + } catch (e) { + node.innerHTML = `
Error loading log: ${e}
`; + } +} + +const userColors = ['#3b82f6', '#a78bfa', '#22c55e', '#f59e0b', '#ef4444', '#22d3ee']; +async function loadUsers() { + const grid = el('users-grid'); + if (!grid) return; + try { + const r = await fetch('/api/users'); + const users = await r.json(); + if (el('nav-badge-users')) el('nav-badge-users').textContent = users.length; + if (el('stat-users')) el('stat-users').textContent = users.length; + if (!users.length) { + grid.innerHTML = '
No users
'; + return; + } + grid.innerHTML = users.map((u, i) => `
${u.name[0].toUpperCase()}
${u.name}
uid ${u.uid}
${u.has_docker ? ' docker' : ''}${u.linger ? 'linger' : ''}${u.has_vdisk ? '💾 vdisk' : ''}
Disk: ${u.disk_used}
Ctrs: ${u.container_count}
`).join(''); + } catch (_) {} +} + +async function loadUserContainers(username) { + selectedUser = username; + if (el('user-detail-panel')) el('user-detail-panel').style.display = ''; + setText('user-detail-title', `${username}'s Containers`); + const body = el('user-containers-body'); + if (!body) return; + body.innerHTML = '
'; + try { + const r = await fetch(`/api/users/${username}/containers`); + const ctrs = await r.json(); + if (!ctrs.length) { + body.innerHTML = '
No containers
'; + return; + } + body.innerHTML = ctrs.map((c) => `${c.name}${c.status.includes('Up') ? 'Running' : 'Stopped'}${c.image}${c.ports || '—'}`).join(''); + } catch (_) {} +} + +function showAlert(id, type, msg) { + const node = el(id); + if (!node) return; + node.className = `alert alert-${type} show`; + node.textContent = msg; +} + +async function createUser() { + const username = el('new-username')?.value.trim(); + const password = el('new-password')?.value.trim(); + const quota = el('new-quota')?.value.trim(); + const docker = !!el('new-docker')?.checked; + if (!username) return showAlert('create-user-result', 'error', 'Username required'); + const btn = event.target; + btn.disabled = true; + try { + const r = await fetch('/api/users/create', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password: password || null, setup_docker: docker, disk_quota_mb: quota ? parseInt(quota, 10) : null }) + }); + const d = await r.json(); + showAlert('create-user-result', d.success ? 'success' : 'error', d.message || ''); + if (d.success) loadUsers(); + } catch (e) { + showAlert('create-user-result', 'error', `${e}`); + } finally { + btn.disabled = false; + } +} + +async function deleteUser() { + if (!selectedUser || !confirm(`Delete user "${selectedUser}"?`)) return; + const removeHome = confirm(`Also delete /home/${selectedUser}?`); + try { + const r = await fetch('/api/users/delete', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: selectedUser, remove_home: removeHome }) + }); + const d = await r.json(); + showAlert('user-action-result', d.success ? 'success' : 'error', d.message || ''); + if (d.success) { + if (el('user-detail-panel')) el('user-detail-panel').style.display = 'none'; + loadUsers(); + } + } catch (e) { + showAlert('user-action-result', 'error', `${e}`); + } +} + +function updateBackupList() { + const src = document.querySelector('input[name="backup_source"]:checked')?.value; + if (!src) return; + if (el('local-options')) el('local-options').style.display = src === 'local' ? '' : 'none'; + if (el('vm-options')) el('vm-options').style.display = src === 'vm' ? '' : 'none'; +} +function toggleRemoteFields() { + const v = document.querySelector('input[name="restore_target"]:checked')?.value; + if (el('remote-fields')) el('remote-fields').style.display = v === 'remote' ? '' : 'none'; +} +function toggleAuthFields() { + const m = document.querySelector('input[name="auth_method"]:checked')?.value; + if (el('key-field')) el('key-field').style.display = m === 'key' ? '' : 'none'; + if (el('password-field')) el('password-field').style.display = m === 'password' ? '' : 'none'; +} + +function quickRestore(source, filename) { + window.location.href = `/restore?source=${encodeURIComponent(source)}&file=${encodeURIComponent(filename)}`; +} + +function appendLog(line) { + const logEl = el('restore-log'); + if (!logEl) return; + const div = document.createElement('div'); + div.className = 'log-line'; + div.textContent = line; + logEl.appendChild(div); + logEl.scrollTop = logEl.scrollHeight; +} + +async function launchRestore() { + const src = document.querySelector('input[name="backup_source"]:checked')?.value; + const file = el('backup-file-select')?.value; + const target = document.querySelector('input[name="restore_target"]:checked')?.value; + if (!file) return alert('Select a backup file.'); + const payload = { backup_source: src, backup_file: file, target }; + if (target === 'remote') { + payload.remote_ip = el('remote-ip')?.value.trim(); + payload.remote_port = el('remote-port')?.value.trim() || '22'; + payload.remote_user = el('remote-user')?.value.trim() || 'root'; + payload.auth_method = document.querySelector('input[name="auth_method"]:checked')?.value; + if (payload.auth_method === 'key') payload.ssh_key_path = el('ssh-key-path')?.value.trim(); + else payload.ssh_password = el('ssh-password')?.value || ''; + if (!payload.remote_ip) return alert('Enter target IP.'); + } + if (!confirm(`Restore "${file}" now?`)) return; + if (el('restore-log-wrapper')) el('restore-log-wrapper').style.display = ''; + if (el('restore-log')) el('restore-log').innerHTML = ''; + try { + const res = await fetch('/restore/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + const data = await res.json(); + if (data.error) return appendLog(`❌ ${data.error}`); + currentJobId = data.job_id; + pollRestore(); + } catch (e) { + appendLog(`❌ ${e}`); + } +} + +function pollRestore() { + if (pollInterval) clearInterval(pollInterval); + let lastLine = 0; + pollInterval = setInterval(async () => { + if (!currentJobId) return; + try { + const r = await fetch(`/restore/status/${currentJobId}`); + const d = await r.json(); + d.log.slice(lastLine).forEach(appendLog); + lastLine = d.log.length; + if (el('restore-elapsed')) el('restore-elapsed').textContent = `⏱ ${d.elapsed}s`; + if (d.status !== 'running') clearInterval(pollInterval); + } catch (_) {} + }, 1500); +} + +function refreshAll() { + const btn = document.querySelector('.icon-btn'); + if (btn) btn.classList.add('spinning'); + Promise.all([checkServerStatus(), refreshSystemMetrics(), refreshContainerStats(), refreshSidebarNavBadges()]) + .finally(() => { if (btn) btn.classList.remove('spinning'); }); +} + +document.addEventListener('DOMContentLoaded', () => { + checkServerStatus(); + refreshSystemMetrics(); + refreshContainerStats(); + refreshSidebarNavBadges(); + if (el('all-containers-body')) loadAllContainers(); + if (el('backup-history-list')) { refreshBackupsList(); loadBackupLog(); } + if (el('users-grid')) loadUsers(); + if (window.restorePrefill && el('backup-file-select')) { + const { source, file } = window.restorePrefill; + if (source) { + const sourceRadio = document.querySelector(`input[name="backup_source"][value="${source}"]`); + if (sourceRadio) sourceRadio.checked = true; + updateBackupList(); + } + if (file) { + const sel = el('backup-file-select'); + for (const opt of sel.options) { + if (opt.value === file) { opt.selected = true; break; } + } + } + } + setInterval(() => { refreshSystemMetrics(); refreshContainerStats(); refreshSidebarNavBadges(); }, 15000); + setInterval(checkServerStatus, 30000); +}); diff --git a/platform/templates/base.html b/platform/templates/base.html index ab088e6..ca287cc 100644 --- a/platform/templates/base.html +++ b/platform/templates/base.html @@ -24,28 +24,28 @@ @@ -67,8 +67,8 @@
-

Dashboard

- {{ main_server }} +

{{ page_title or 'Dashboard' }}

+ {{ page_subtitle or main_server }}
- + +{% endblock %} diff --git a/platform/templates/pages/settings.html b/platform/templates/pages/settings.html new file mode 100644 index 0000000..28a7a7c --- /dev/null +++ b/platform/templates/pages/settings.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +
+
Platform Settings
+
+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/platform/templates/pages/users.html b/platform/templates/pages/users.html new file mode 100644 index 0000000..fc7514c --- /dev/null +++ b/platform/templates/pages/users.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block content %} +
+
Create New User
+
+
+
+
+
+ + +
+
+
+ +
+ +
+
+
System Users
+ +
+
+ {% for u in users %} +
+
{{ u.name[0].upper() }}
{{ u.name }}
uid {{ u.uid }}
+
{% if u.has_docker %} docker{% endif %}{% if u.linger %}linger{% endif %}{% if u.has_vdisk %}💾 vdisk{% endif %}
+
Disk: {{ u.disk_used }}
Ctrs: {{ u.container_count }}
+
+ {% else %} +
No non-system users
+ {% endfor %} +
+
+ + +{% endblock %} diff --git a/scripts/backup-myapps.sh b/scripts/backup-myapps.sh index 9629b9b..8c33dae 100755 --- a/scripts/backup-myapps.sh +++ b/scripts/backup-myapps.sh @@ -19,12 +19,25 @@ VM_PORT="2223" VM_KEY="/root/.ssh/contabo-key" VM_DEST="/backups/main-server/" +# Log file for backup status (used by boot-check script) +BACKUP_LOG_FILE="/root/backups/backup-status.log" +MAX_BACKUPS=10 + +# ── Write status to log ────────────────────────────────────────────────────── +log_status() { + local status="$1" # SUCCESS or FAILED + local name="$2" + local msg="${3:-}" + echo "$(date '+%Y-%m-%d %H:%M:%S') | ${status} | ${name} | ${msg}" >> "$BACKUP_LOG_FILE" +} + echo "=========================================" echo "📦 Starting Backup: $BACKUP_NAME" echo " Apps: Frappe, Nextcloud, Mautic, n8n, Odoo" echo "=========================================" mkdir -p "$BACKUP_DIR" +mkdir -p "/root/backups" cd "$BACKUP_DIR" # -------------------------------------------------- @@ -77,7 +90,6 @@ VOLUMES=( ) for volume in "${VOLUMES[@]}"; do - # Skip volumes that don't exist on this host if ! docker volume inspect "$volume" &>/dev/null; then echo " ⏭️ $volume — not found, skipping" continue @@ -122,7 +134,6 @@ docker run --rm \ && echo " ✅ Nextcloud config.php" \ || echo " ⏭️ Nextcloud config not found" -# Frappe site config docker exec frappe-erpnext \ cat /home/frappe/frappe-bench/sites/erpnext.navitrends.ovh/site_config.json \ > configs/frappe-site_config.json 2>/dev/null \ @@ -130,7 +141,7 @@ docker exec frappe-erpnext \ || echo " ⏭️ Frappe config not found" # -------------------------------------------------- -# 6. Backup metadata +# 6. Backup metadata + checksum # -------------------------------------------------- echo "" echo "📝 [6/7] Writing backup metadata..." @@ -146,6 +157,14 @@ Docker info: $(docker --version) Volumes included: $(ls volumes/*.tar.gz 2>/dev/null | xargs -I{} basename {} || echo "none") EOF + +# Write individual volume checksums for integrity verification later +echo "" >> backup-info.txt +echo "Volume SHA256 checksums:" >> backup-info.txt +for f in volumes/*.tar.gz; do + [ -f "$f" ] && sha256sum "$f" | awk '{print $1 " " $2}' >> backup-info.txt || true +done + echo " ✅ Done" # -------------------------------------------------- @@ -158,8 +177,35 @@ tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}/" COMPRESSED_SIZE=$(du -h "${BACKUP_NAME}.tar.gz" | cut -f1) echo " ✅ Compressed size: $COMPRESSED_SIZE → $BACKUP_ARCHIVE" +# Write a top-level SHA256 for the final archive (used by health-check) +sha256sum "${BACKUP_NAME}.tar.gz" > "${BACKUP_NAME}.tar.gz.sha256" +echo " ✅ Checksum written: ${BACKUP_NAME}.tar.gz.sha256" + +# Remove staging directory now that archive is created +rm -rf "$BACKUP_DIR" + # -------------------------------------------------- -# Send to VM over SSH +# 8. Retention — keep only the latest MAX_BACKUPS +# -------------------------------------------------- +echo "" +echo "🧹 [Retention] Keeping latest ${MAX_BACKUPS} backups..." +ARCHIVE_LIST=$(ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null || true) +ARCHIVE_COUNT=$(echo "$ARCHIVE_LIST" | grep -c '.tar.gz' || true) + +if [ "$ARCHIVE_COUNT" -gt "$MAX_BACKUPS" ]; then + TO_DELETE=$(echo "$ARCHIVE_LIST" | tail -n +$((MAX_BACKUPS + 1))) + while IFS= read -r old_file; do + [ -z "$old_file" ] && continue + rm -f "$old_file" + rm -f "${old_file}.sha256" + echo " 🗑️ Deleted old backup: $(basename $old_file)" + done <<< "$TO_DELETE" +else + echo " ✅ ${ARCHIVE_COUNT}/${MAX_BACKUPS} backups — nothing to prune" +fi + +# -------------------------------------------------- +# 9. Send to VM over SSH # -------------------------------------------------- echo "" echo "📤 Sending backup to VM (${VM_HOST}:${VM_PORT})..." @@ -172,7 +218,9 @@ scp -i "$VM_KEY" \ if [ $? -eq 0 ]; then echo " ✅ Backup sent to VM successfully!" - echo " 💡 On the VM: ls ${VM_DEST}" + # Also send the checksum file + scp -i "$VM_KEY" -P "$VM_PORT" -o StrictHostKeyChecking=no \ + "${BACKUP_NAME}.tar.gz.sha256" "${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true else echo " ❌ Transfer failed. The compressed backup is still at:" echo " $BACKUP_ARCHIVE" @@ -180,6 +228,11 @@ else echo " scp -i $VM_KEY -P $VM_PORT $BACKUP_ARCHIVE ${VM_USER}@${VM_HOST}:${VM_DEST}" fi +# -------------------------------------------------- +# 10. Write final status to log +# -------------------------------------------------- +log_status "SUCCESS" "$BACKUP_NAME" "size=${COMPRESSED_SIZE}" + echo "" echo "=========================================" echo "✅ BACKUP COMPLETE"