Sync from main server - 2026-04-18 18:47:38

This commit is contained in:
root
2026-04-18 18:47:38 +02:00
parent 81347bbdd2
commit a8db6b5fa2
12 changed files with 2073 additions and 243 deletions

View File

@@ -17,7 +17,9 @@ from modules.backups import (
get_local_backups, get_vm_backups, get_local_backups, get_vm_backups,
get_all_stats, get_system_info, get_all_stats, get_system_info,
get_rootless_user_containers_remote, 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.commands import run_command
from modules.users import ( from modules.users import (
@@ -28,7 +30,8 @@ from modules.users import (
app = Flask(__name__) app = Flask(__name__)
app.secret_key = 'navitrends-secret-key-2025' app.secret_key = 'navitrends-secret-key-2025'
restore_jobs = {} restore_jobs = {}
backup_jobs = {} # for manual backup runs
def _stream_restore(job_id, cmd): def _stream_restore(job_id, cmd):
@@ -49,6 +52,25 @@ def _stream_restore(job_id, cmd):
restore_jobs[job_id]['status'] = 'error' 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 # DASHBOARD
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -60,9 +82,8 @@ def dashboard():
backups = get_local_backups() backups = get_local_backups()
vm_backups = get_vm_backups() vm_backups = get_vm_backups()
system = get_system_info() system = get_system_info()
# Users are still LOCAL (users on the platform host)
users = get_all_users() users = get_all_users()
return render_template('dashboard.html', return render_template('pages/dashboard.html',
containers=containers, containers=containers,
running_count=running_count, running_count=running_count,
backups=backups, backups=backups,
@@ -70,7 +91,82 @@ def dashboard():
main_server=MAIN_SERVER_IP, main_server=MAIN_SERVER_IP,
system=system, system=system,
users=users, 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}) 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 # API — container actions
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -115,7 +225,8 @@ def api_containers_all():
def api_container_action(): def api_container_action():
""" """
POST JSON: { "name": "container-name", "action": "start|stop|restart" } 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 {} data = request.get_json() or {}
name = data.get('name', '').strip() name = data.get('name', '').strip()
@@ -125,7 +236,28 @@ def api_container_action():
return jsonify({'success': False, 'message': 'name and action required'}), 400 return jsonify({'success': False, 'message': 'name and action required'}), 400
success, output = container_action(name, action) 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/<name>')
@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()}) 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/<job_id>.
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/<job_id>')
@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) # API — users (LOCAL — users on this host)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -218,13 +444,11 @@ def restore_start():
# ── Resolve backup archive path ────────────────────────────────────────── # ── Resolve backup archive path ──────────────────────────────────────────
if backup_source == 'local': if backup_source == 'local':
# Backup is on main server at /root/backups/
if RUNNING_ON_MAIN_SERVER: if RUNNING_ON_MAIN_SERVER:
backup_path = f"/root/backups/{backup_file}" backup_path = f"/root/backups/{backup_file}"
if not os.path.exists(backup_path): if not os.path.exists(backup_path):
return jsonify({'error': f'Not found: {backup_path}'}), 400 return jsonify({'error': f'Not found: {backup_path}'}), 400
else: else:
# We're on VM → need to pull backup from main server to /tmp/ first
backup_path = f"/tmp/{backup_file}" backup_path = f"/tmp/{backup_file}"
if not os.path.exists(backup_path): if not os.path.exists(backup_path):
pull_cmd = ( pull_cmd = (
@@ -237,9 +461,7 @@ def restore_start():
if res.returncode != 0: if res.returncode != 0:
return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500 return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500
else: else:
# VM backup
if RUNNING_ON_MAIN_SERVER: if RUNNING_ON_MAIN_SERVER:
# Pull from VM via tunnel
backup_path = f"/tmp/{backup_file}" backup_path = f"/tmp/{backup_file}"
if not os.path.exists(backup_path): if not os.path.exists(backup_path):
pull_cmd = ( pull_cmd = (
@@ -252,7 +474,6 @@ def restore_start():
if res.returncode != 0: if res.returncode != 0:
return jsonify({'error': f'Failed to pull from VM: {res.stderr}'}), 500 return jsonify({'error': f'Failed to pull from VM: {res.stderr}'}), 500
else: else:
# We're on VM → backup is local
backup_path = f"/backups/main-server/{backup_file}" backup_path = f"/backups/main-server/{backup_file}"
if not os.path.exists(backup_path): if not os.path.exists(backup_path):
return jsonify({'error': f'Not found: {backup_path}'}), 400 return jsonify({'error': f'Not found: {backup_path}'}), 400
@@ -263,9 +484,6 @@ def restore_start():
if not os.path.exists(restore_script_local): if not os.path.exists(restore_script_local):
return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500 return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500
# ── 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': if target == 'local':
hostname = os.uname().nodename hostname = os.uname().nodename
session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}" session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}"
@@ -282,7 +500,6 @@ def restore_start():
) )
else: else:
# Explicit remote machine (custom IP)
if not remote_ip: if not remote_ip:
return jsonify({'error': 'remote_ip required'}), 400 return jsonify({'error': 'remote_ip required'}), 400

View File

@@ -3,6 +3,9 @@ import os
import glob import glob
import subprocess import subprocess
import json import json
import hashlib
import tarfile
import re
from config import ( from config import (
RUNNING_ON_MAIN_SERVER, RUNNING_ON_MAIN_SERVER,
MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT, MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT,
@@ -18,12 +21,21 @@ def _run(cmd, timeout=20):
return '', str(e) 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): 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: if RUNNING_ON_MAIN_SERVER:
return _run(remote_cmd, timeout=timeout) return _run(remote_cmd, timeout=timeout)
else: 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(): 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( stdout, _ = _ssh_main(
"ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null | head -20" "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(): 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 = [] vm_backups = []
if RUNNING_ON_MAIN_SERVER: if RUNNING_ON_MAIN_SERVER:
try: try:
cmd = ( cmd = (
@@ -81,18 +83,397 @@ def get_vm_backups():
except Exception as e: except Exception as e:
print(f"[backups] VM backup fetch error: {e}") print(f"[backups] VM backup fetch error: {e}")
else: else:
# We ARE on the VM — read directly
backup_dir = '/backups/main-server' backup_dir = '/backups/main-server'
if os.path.exists(backup_dir): if os.path.exists(backup_dir):
files = glob.glob(f'{backup_dir}/myapps-backup-*.tar.gz') files = glob.glob(f'{backup_dir}/myapps-backup-*.tar.gz')
files.sort(key=os.path.getmtime, reverse=True) files.sort(key=os.path.getmtime, reverse=True)
vm_backups = [os.path.basename(f) for f in files[:20]] vm_backups = [os.path.basename(f) for f in files[:20]]
return vm_backups 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'): def _parse_containers(raw, owner='root'):
@@ -113,7 +494,6 @@ def _parse_containers(raw, owner='root'):
def get_containers(): def get_containers():
"""App containers only (frappe/nextcloud/mautic/n8n/odoo) — always from main server."""
stdout, _ = _ssh_main( stdout, _ = _ssh_main(
"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null | " "docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null | "
"grep -E 'frappe|nextcloud|mautic|n8n|odoo'" "grep -E 'frappe|nextcloud|mautic|n8n|odoo'"
@@ -122,7 +502,6 @@ def get_containers():
def get_all_root_containers(): def get_all_root_containers():
"""ALL root docker containers (unfiltered) — always from main server."""
stdout, _ = _ssh_main( stdout, _ = _ssh_main(
"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}' 2>/dev/null" "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(): 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" cmd = "ls /run/user/*/docker.sock 2>/dev/null"
stdout, _ = _ssh_main(cmd) stdout, _ = _ssh_main(cmd)
containers = [] containers = []
if not stdout: if not stdout:
return containers return containers
for sock_path in stdout.split('\n'): for sock_path in stdout.split('\n'):
sock_path = sock_path.strip() sock_path = sock_path.strip()
if not sock_path: if not sock_path:
continue continue
# Determine username from uid
try: try:
uid = sock_path.split('/run/user/')[1].split('/')[0] uid = sock_path.split('/run/user/')[1].split('/')[0]
except (IndexError, ValueError): except (IndexError, ValueError):
continue continue
name_out, _ = _ssh_main(f"getent passwd {uid} | cut -d: -f1") name_out, _ = _ssh_main(f"getent passwd {uid} | cut -d: -f1")
username = name_out.strip() or f"uid{uid}" username = name_out.strip() or f"uid{uid}"
ctr_out, _ = _ssh_main( ctr_out, _ = _ssh_main(
f"DOCKER_HOST=unix://{sock_path} " f"DOCKER_HOST=unix://{sock_path} "
f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null" f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null"
) )
containers.extend(_parse_containers(ctr_out, owner=username)) containers.extend(_parse_containers(ctr_out, owner=username))
return containers return containers
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
# CONTAINER ACTIONS (start / stop / restart) — on main server # CONTAINER ACTIONS
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
def container_action(container_name, action): def container_action(container_name, action):
"""
action: 'start' | 'stop' | 'restart'
Returns (success: bool, output: str)
"""
if action not in ('start', 'stop', 'restart'): if action not in ('start', 'stop', 'restart'):
return False, "Invalid action" return False, "Invalid action"
safe_name = container_name.replace('"', '').replace(';', '').replace('|', '') safe_name = container_name.replace('"', '').replace(';', '').replace('|', '')
stdout, stderr = _ssh_main( stdout, stderr = _ssh_main(f"docker {action} {safe_name} 2>&1", timeout=30)
f"docker {action} {safe_name} 2>&1",
timeout=30
)
output = (stdout + stderr).strip() output = (stdout + stderr).strip()
success = safe_name in output or 'started' in output.lower() or stderr == ''
return True, output 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(): def get_container_stats_remote():
"""One-shot stats for all root containers on main server."""
stdout, _ = _ssh_main( stdout, _ = _ssh_main(
"docker stats --no-stream --format " "docker stats --no-stream --format "
"'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null", "'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null",
@@ -216,10 +590,7 @@ def get_container_stats_remote():
def get_all_stats(): def get_all_stats():
"""Stats for root containers on main server + rootless users on main server."""
all_stats = get_container_stats_remote() 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") socks_out, _ = _ssh_main("ls /run/user/*/docker.sock 2>/dev/null")
if socks_out: if socks_out:
for sock in socks_out.split('\n'): 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(): 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}'") 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_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}'") 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}'") load_out, _ = _ssh_main("cat /proc/loadavg | awk '{print $1, $2, $3}'")
uptime, _ = _ssh_main("uptime -p") uptime, _ = _ssh_main("uptime -p")
docker_v, _ = _ssh_main("docker --version | cut -d' ' -f3 | tr -d ','") 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 { return {
'cpu_pct': cpu_out or '0', 'cpu_pct': cpu_out or '0',
'memory': mem_out or 'N/A', 'memory': mem_out or 'N/A',
@@ -274,5 +643,5 @@ def get_system_info():
'load': load_out or 'N/A', 'load': load_out or 'N/A',
'uptime': uptime or 'N/A', 'uptime': uptime or 'N/A',
'docker_v': docker_v or 'N/A', 'docker_v': docker_v or 'N/A',
'hostname': hostname or 'this server', 'hostname': hostname or 'main server',
} }

View File

@@ -1,9 +1,12 @@
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
Navitrends Ops Platform Ops Platform — Server 173.249.20.244
Fonts: Syne (display) + Geist Mono (data/code) 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) ──────────────────────────────────── */ /* ── DARK THEME (default) ──────────────────────────────────── */
:root { :root {
--bg: #0a0b0e; --bg: #0a0b0e;
@@ -31,6 +34,14 @@
--font: 'Syne', sans-serif; --font: 'Syne', sans-serif;
--mono: 'Geist Mono', monospace; --mono: 'Geist Mono', monospace;
--trans: 0.18s ease; --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 ───────────────────────────────────────────── */ /* ── LIGHT THEME ───────────────────────────────────────────── */
@@ -54,6 +65,10 @@
--cyan: #0891b2; --cyan: #0891b2;
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06); --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 ─────────────────────────────────────────────────── */ /* ── RESET ─────────────────────────────────────────────────── */
@@ -75,26 +90,38 @@ html, body {
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
/* ── LAYOUT ────────────────────────────────────────────────── */ /* ── LAYOUT ────────────────────────────────────────────────── */
.layout { display: flex; height: 100vh; overflow: hidden; } .layout { display: flex; min-height: 100vh; }
/* ── SIDEBAR ───────────────────────────────────────────────── */ /* ── SIDEBAR ───────────────────────────────────────────────── */
.sidebar { .sidebar {
width: 220px; min-width: 220px; width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); 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; padding: 20px 12px;
transition: background 0.3s, border-color 0.3s; transition: background 0.3s, border-color 0.3s;
} }
.sidebar-brand { .sidebar-brand,
display: flex; align-items: center; gap: 12px; a.sidebar-brand-link {
display: flex;
align-items: center;
gap: 12px;
padding: 0 8px 24px; padding: 0 8px 24px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: 20px; margin-bottom: 20px;
text-decoration: none;
color: inherit;
} }
.brand-mark { .brand-mark {
width: 36px; height: 36px; background: var(--accent); width: 36px; height: 36px;
background: var(--accent);
border-radius: 8px; border-radius: 8px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-weight: 500; font-size: 12px; color: #fff; 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-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; } .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 { .nav-section-label {
font-size: 9px; letter-spacing: 0.12em; color: var(--text3); font-size: 9px; letter-spacing: 0.12em; color: var(--text3);
font-family: var(--mono); padding: 0 8px; font-family: var(--mono); padding: 0 8px;
margin-bottom: 6px; margin-top: 4px; margin-bottom: 6px; margin-top: 8px;
} }
.nav-item { .nav-item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -115,22 +144,27 @@ html, body {
color: var(--text2); text-decoration: none; color: var(--text2); text-decoration: none;
font-size: 13px; font-weight: 500; cursor: pointer; font-size: 13px; font-weight: 500; cursor: pointer;
transition: background var(--trans), color var(--trans); 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:hover { background: var(--surface2); color: var(--text); }
.nav-item.active { background: var(--accent); color: #fff; } .nav-item.active { background: var(--accent); color: #fff; }
.nav-item i { width: 16px; text-align: center; font-size: 13px; }
.nav-badge { .nav-badge {
margin-left: auto; font-family: var(--mono); font-size: 10px; margin-left: auto; font-family: var(--mono); font-size: 10px;
background: var(--border2); color: var(--text2); background: var(--border2); color: var(--text2);
padding: 2px 6px; border-radius: 20px; 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 { .sidebar-footer {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding-top: 16px; border-top: 1px solid var(--border); margin-top: auto; 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; flex: 1; display: flex; align-items: center; gap: 7px;
background: var(--surface2); border-radius: 20px; background: var(--surface2); border-radius: 20px;
padding: 6px 10px; font-family: var(--mono); 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); } 50% { box-shadow: 0 0 0 4px rgba(34,197,94,0); }
} }
.logout-btn { .logout-btn,
.icon-btn-sm {
width: 32px; height: 32px; width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
border-radius: 8px; color: var(--text3); text-decoration: none; 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); 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 { .theme-toggle {
width: 32px; height: 32px; width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -167,17 +204,25 @@ html, body {
.theme-toggle:hover { background: var(--border2); color: var(--text); } .theme-toggle:hover { background: var(--border2); color: var(--text); }
/* ── MAIN ──────────────────────────────────────────────────── */ /* ── 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 { .topbar {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 18px 28px; border-bottom: 1px solid var(--border); padding: 18px 28px; border-bottom: 1px solid var(--border);
background: var(--surface); flex-shrink: 0; background: var(--surface); flex-shrink: 0;
position: sticky; top: 0; z-index: 50;
transition: background 0.3s, border-color 0.3s; transition: background 0.3s, border-color 0.3s;
} }
.topbar-left { display: flex; align-items: baseline; gap: 12px; } .topbar-left { display: flex; align-items: baseline; gap: 12px; }
.page-title { font-size: 20px; font-weight: 700; } .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; } .topbar-right { display: flex; align-items: center; gap: 10px; }
.icon-btn { .icon-btn {
@@ -188,7 +233,8 @@ html, body {
transition: background var(--trans), color var(--trans); font-size: 13px; transition: background var(--trans), color var(--trans); font-size: 13px;
} }
.icon-btn:hover { background: var(--border2); color: var(--text); } .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); } } @keyframes spin { to { transform: rotate(360deg); } }
.uptime-chip { .uptime-chip {
@@ -197,18 +243,8 @@ html, body {
padding: 5px 12px; border-radius: 20px; padding: 5px 12px; border-radius: 20px;
} }
.content { /* ── CONTENT ───────────────────────────────────────────────── */
flex: 1; overflow-y: auto; padding: 24px 28px; .content { flex: 1; padding: 24px 28px; display: flex; flex-direction: column; gap: 20px; }
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); }
}
/* ── CARDS ─────────────────────────────────────────────────── */ /* ── CARDS ─────────────────────────────────────────────────── */
.card { .card {
@@ -216,6 +252,7 @@ html, body {
border-radius: var(--radius-lg); padding: 20px; border-radius: var(--radius-lg); padding: 20px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: background 0.3s, border-color 0.3s; transition: background 0.3s, border-color 0.3s;
margin-bottom: 0;
} }
.card-header { .card-header {
display: flex; align-items: center; justify-content: space-between; 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); display: flex; align-items: center; gap: 8px; color: var(--text);
} }
.card-title i { color: var(--accent2); font-size: 12px; } .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 ───────────────────────────────────────────── */
.metrics-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; } .metrics-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.metric-card { .metric-card {
background: var(--surface2); border: 1px solid var(--border); 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; transition: background 0.3s, border-color 0.3s;
} }
.metric-card::before { .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:nth-child(2)::before { background: var(--purple); }
.metric-card.disk::before { background: var(--cyan); } .metric-card:nth-child(3)::before { background: var(--cyan); }
.metric-card.load::before { background: var(--yellow); } .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-label {
.metric-value { font-family: var(--mono); font-size: 22px; font-weight: 500; color: var(--text); line-height: 1; margin-bottom: 10px; } font-family: var(--mono); font-size: 9px; font-weight: 700;
.metric-value span { font-size: 12px; color: var(--text3); } letter-spacing: 0.12em; color: var(--text3); margin-bottom: 8px;
.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-card.mem .gauge-fill { background: var(--purple); } .metric-value {
.metric-card.disk .gauge-fill { background: var(--cyan); } font-family: var(--mono); font-size: 26px; font-weight: 700;
.metric-card.load .gauge-fill { background: var(--yellow); } 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 ──────────────────────────────────────────────── */
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; } /* 173 uses stat-box; 83 uses stat-card — support both */
.stat-card { .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); background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; text-align: center; border-radius: var(--radius); padding: 14px 16px;
transition: background 0.3s;
} }
.stat-number { font-family: var(--mono); font-size: 28px; font-weight: 500; color: var(--accent2); line-height: 1; } .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; } .stat-label { font-size: 11px; color: var(--text3); margin-top: 5px; letter-spacing: 0.04em; }
/* ── CONTAINERS TABLE ──────────────────────────────────────── */ /* ── TABLES ────────────────────────────────────────────────── */
.ct-table { width: 100%; border-collapse: collapse; } .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 { .ct-table th {
font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em; text-align: left;
color: var(--text3); padding: 6px 10px; 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); border-bottom: 1px solid var(--border);
} }
.data-table td,
.ct-table td { .ct-table td {
padding: 10px; border-bottom: 1px solid var(--border); padding: 10px 12px; border-bottom: 1px solid var(--border);
font-size: 13px; vertical-align: middle; vertical-align: middle;
} }
.data-table tr:last-child td,
.ct-table tr:last-child td { border-bottom: none; } .ct-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td,
.ct-table tr:hover td { background: var(--surface2); } .ct-table tr:hover td { background: var(--surface2); }
.ct-name { font-family: var(--mono); font-size: 13px; color: var(--text); font-weight: 500; } .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: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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); } .ct-ports { font-family: var(--mono); font-size: 11px; color: var(--cyan); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ct-owner { font-family: var(--mono); font-size: 11px; color: var(--purple); } .mono { font-family: var(--mono); }
.dim { color: var(--text3); font-size: 12px; }
/* ── BADGES ────────────────────────────────────────────────── */ /* ── BADGES ────────────────────────────────────────────────── */
.badge { .badge {
@@ -290,80 +378,94 @@ html, body {
padding: 3px 9px; border-radius: 20px; padding: 3px 9px; border-radius: 20px;
font-family: var(--mono); font-size: 11px; font-weight: 500; font-family: var(--mono); font-size: 11px; font-weight: 500;
} }
.badge-run { background: rgba(34,197,94,0.12); color: var(--green); } /* support both naming conventions */
.badge-stop { background: rgba(239,68,68,0.12); color: var(--red); } .badge-run, .badge.run { background: rgba(34,197,94,0.12); color: var(--green); border: 1px solid rgba(34,197,94,0.2); }
.badge-run::before { content:''; width:5px; height:5px; background:var(--green); border-radius:50%; } .badge-stop, .badge.stop { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.2); }
.badge-stop::before { content:''; width:5px; height:5px; background:var(--red); border-radius:50%; } .badge-run::before, .badge.run::before { content:''; width:5px; height:5px; background:var(--green); border-radius:50%; }
[data-theme="light"] .badge-run { background: rgba(22,163,74,0.1); } .badge-stop::before, .badge.stop::before { content:''; width:5px; height:5px; background:var(--red); border-radius:50%; }
[data-theme="light"] .badge-stop { background: rgba(220,38,38,0.1); } [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 ──────────────────────────────────────── */ /* ── INLINE STAT BARS ──────────────────────────────────────── */
.stat-bar-wrap { display: flex; align-items: center; gap: 6px; min-width: 90px; } /* 173 uses bar-wrap/bar-bg/bar-fill; 83 uses stat-bar-wrap etc — support both */
.stat-bar-bg { flex: 1; height: 4px; background: var(--border2); border-radius: 4px; overflow: hidden; } .stat-bar-wrap,
.stat-bar-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width 0.5s ease; } .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); } .stat-bar-fill.warn { background: var(--yellow); }
.bar-fill.crit,
.stat-bar-fill.crit { background: var(--red); } .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 ────────────────────────────────────────────── */ /* ── ACTION BUTTONS ────────────────────────────────────────── */
.users-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; } /* unified: 83 uses ctr-action-btn; 173 uses act-btn — both work */
.user-card { .action-btns { display: flex; gap: 4px; align-items: center; }
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); }
.user-stats { display: flex; gap: 16px; margin-top: 10px; } .ctr-action-btn,
.user-stat { font-family: var(--mono); font-size: 11px; color: var(--text3); } .act-btn {
.user-stat strong { color: var(--text); } 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 ──────────────────────────────────────────── */
.restore-form { display: flex; flex-direction: column; gap: 0; } .restore-form { max-width: 700px; display: flex; flex-direction: column; gap: 0; }
.form-section { padding: 20px 0; border-bottom: 1px solid var(--border); } .form-section { padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 0; }
.form-section:last-child { border-bottom: none; } .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; } .form-section-title {
font-size: 9px; font-family: var(--mono); font-weight: 700;
.radio-group { display: flex; gap: 10px; flex-wrap: wrap; } letter-spacing: 0.12em; color: var(--text3); margin-bottom: 14px;
.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);
} }
.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-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 { .form-input {
background: var(--surface2); border: 1px solid var(--border2); background: var(--surface2); border: 1px solid var(--border2);
border-radius: 8px; padding: 9px 12px; font-size: 13px; 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:focus { outline: none; border-color: var(--accent); }
.form-input[readonly] { opacity: 0.6; cursor: default; } .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 { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
.form-check input { accent-color: var(--accent); width: 14px; height: 14px; } .form-check input { accent-color: var(--accent); width: 14px; height: 14px; }
/* ── BUTTONS ───────────────────────────────────────────────── */ .radio-group { display: flex; gap: 10px; flex-wrap: wrap; }
.btn { .radio-card { cursor: pointer; flex: 1; min-width: 160px; }
display: inline-flex; align-items: center; gap: 7px; .radio-card.sm { flex: unset; }
padding: 9px 16px; border-radius: 8px; .radio-card input { display: none; }
font-size: 13px; font-weight: 600; font-family: var(--font); .radio-body {
cursor: pointer; border: none; display: flex; align-items: center; gap: 10px;
transition: opacity var(--trans), background var(--trans), filter var(--trans); 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); } .radio-card.sm .radio-body { padding: 9px 12px; min-width: 110px; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } .radio-card input:checked + .radio-body { border-color: var(--accent); background: rgba(59,130,246,0.08); }
.btn-primary { background: var(--accent); color: #fff; } [data-theme="light"] .radio-card input:checked + .radio-body { background: rgba(37,99,235,0.06); }
.btn-danger { background: var(--red); color: #fff; } .radio-icon { font-size: 16px; color: var(--accent2); }
.btn-success { background: var(--green); color: #fff; } .radio-label { font-size: 13px; font-weight: 600; }
.btn-ghost { background: var(--surface2); border: 1px solid var(--border2); color: var(--text2); } .radio-desc { font-size: 11px; color: var(--text3); font-family: var(--mono); margin-top: 2px; }
.btn-ghost:hover { background: var(--border2); color: var(--text); filter: none; } .radio-desc code { background: var(--border2); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); }
.btn-sm { padding: 5px 11px; font-size: 12px; }
.btn-lg { padding: 12px 24px; font-size: 14px; }
/* ── LOG CONSOLE ───────────────────────────────────────────── */ /* ── 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 { .log-console {
background: #05060a; border: 1px solid var(--border); background: #05060a; border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; border-radius: var(--radius); padding: 14px 16px;
font-family: var(--mono); font-size: 12px; line-height: 1.7; font-family: var(--mono); font-size: 12px; line-height: 1.7;
max-height: 400px; overflow-y: auto; color: #8892a4; 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; } [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 ───────────────────────────────────────────── */
.backup-list { display: flex; flex-direction: column; gap: 8px; } .backup-list { display: flex; flex-direction: column; gap: 8px; }
@@ -411,28 +522,101 @@ html, body {
background: var(--surface2); gap: 10px; flex-wrap: wrap; background: var(--surface2); gap: 10px; flex-wrap: wrap;
border: 1px solid var(--border); 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 ────────────────────────────────────────────── */ /* ── MODAL ─────────────────────────────────────────────────── */
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .modal-overlay {
@media (max-width: 768px) { .two-col { grid-template-columns: 1fr; } } 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 ─────────────────────────────────────────────── */ /* ── NOTICE ────────────────────────────────────────────────── */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } .notice {
@media (max-width: 600px) { .form-grid { grid-template-columns: 1fr; } } padding: 12px 16px; border-radius: var(--radius);
font-size: 13px; display: flex; align-items: flex-start; gap: 10px;
/* ── SECTION HEADER ────────────────────────────────────────── */ }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .notice.warning {
.section-title { font-size: 13px; font-weight: 600; color: var(--text2); font-family: var(--mono); letter-spacing: 0.06em; } 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 ────────────────────────────────────────────────── */ /* ── ALERTS ────────────────────────────────────────────────── */
.alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; display: none; } .alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; display: none; }
.alert.show { display: block; } .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-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); } .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); }
/* ── EMPTY STATE ───────────────────────────────────────────── */ /* ── 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-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; }

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 `<div class="empty-state" style="color:var(--red)">${escapeHtml(d.error)}</div>
<div class="audit-footer"><button type="button" class="btn btn-ghost btn-sm" onclick="closeAuditModal()">Close</button></div>`;
}
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
? `<ul class="audit-check-more">${moreLines.map((line) => `<li>${escapeHtml(line)}</li>`).join('')}</ul>`
: '';
return `<details class="audit-check-row status-${st}">
<summary class="audit-check-summary">
<span class="${iconCls}">${icon}</span>
<span class="audit-check-name">${escapeHtml(c.name)}</span>
<span class="audit-check-pill audit-check-pill-${st}">${st}</span>
<span class="audit-check-chevron" aria-hidden="true"></span>
</summary>
<div class="audit-check-body">
<p class="audit-check-detail">${detail}</p>
${moreBlock}
</div>
</details>`;
}).join('');
return `<div class="audit-file-label">File</div>
<code class="audit-filename">${fname}</code>
<div class="audit-file-meta"><span class="audit-file-meta-label">Size</span><span class="audit-file-meta-value">${fileSize}</span></div>
<div class="audit-score-block ${tierCls}">
<div class="audit-score-numcol">
<div class="audit-score-number">${score}</div>
<div class="audit-score-label">Score</div>
</div>
<div class="audit-score-info">
<div class="audit-health-row">
<span class="audit-health-label">${healthLabel}</span>
<span class="${badgeClass}">${badgeText}</span>
</div>
<p class="audit-summary">${summary}</p>
<div class="audit-progress" role="progressbar" aria-valuenow="${score}" aria-valuemin="0" aria-valuemax="100"><div class="audit-progress-fill ${tierCls}" style="width:${score}%"></div></div>
</div>
</div>
<p class="audit-checks-hint">Expand a row to see exactly what was verified (size, checksum, archive test, paths, …).</p>
<div class="audit-checks">${checksHtml}</div>
<div class="audit-footer">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeAuditModal()">Close</button>
<button type="button" class="btn btn-primary btn-sm" onclick="quickRestoreFromAuditModal()"><i class="fas fa-rotate-left"></i> Restore this backup</button>
</div>`;
}
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
? '<i class="fas fa-eye-slash"></i> Show less'
: '<i class="fas fa-eye"></i> 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 + '<span>%</span>';
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 '<span class="badge badge-run">Running</span>';
if (status === 'stopped') return '<span class="badge badge-stop">Stopped</span>';
return '<span class="badge" style="background:rgba(107,114,128,0.15);color:var(--text2)">Unknown</span>';
}
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 = '<i class="fas fa-spinner fa-spin"></i>';
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 ? '<i class="fas fa-check"></i>' : '<i class="fas fa-times"></i>';
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 `<div class="action-btns">
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${name}','restart',this)"><i class="fas fa-rotate-right"></i></button>
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('${name}','stop',this)"><i class="fas fa-stop"></i></button>
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('${name}','start',this)"><i class="fas fa-play"></i></button>
</div>`;
}
async function loadAllContainers() {
const body = el('all-containers-body');
if (!body) return;
const meta = el('all-ctr-meta');
body.innerHTML = '<tr><td colspan="10"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>';
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 = '<tr><td colspan="10"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
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 `<tr>
<td class="ct-name">${c.name}</td><td class="ct-owner">${c.owner}</td>
<td>${up ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td>
<td><span class="stat-pct">${s.cpu || '—'}</span></td>
<td><div class="stat-bar-wrap"><div class="stat-bar-bg"><div class="stat-bar-fill ${cls}" style="width:${Math.min(pct,100)}%"></div></div><span class="stat-pct">${s.mem_pct || '—'}</span></div></td>
<td><span class="stat-pct" style="color:var(--cyan)">${s.net || '—'}</span></td>
<td class="col-extra all-extra" style="${ed}"><span class="stat-pct" style="color:var(--yellow)">${s.block || '—'}</span></td>
<td class="col-extra all-extra ct-image" style="${ed}">${c.image}</td>
<td class="col-extra all-extra ct-ports" style="${ed}">${c.ports || '—'}</td>
<td>${buildActionBtns(c.name)}</td></tr>`;
}).join('');
} catch (e) {
body.innerHTML = `<tr><td colspan="10"><div class="empty-state">${e}</div></td></tr>`;
}
}
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) => `<option value="${b}" data-source="local">${b}</option>`).join('') : '<option disabled>No local backups</option>';
if (el('vm-options')) el('vm-options').innerHTML = d.vm.length ? d.vm.map((b) => `<option value="${b}" data-source="vm">${b}</option>`).join('') : '<option disabled>No VM backups</option>';
} catch (_) {}
}
function renderBackupList(items, id, source) {
const node = el(id);
if (!node) return;
if (!items || !items.length) {
node.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>';
return;
}
node.innerHTML = items.map((b) => `
<div class="backup-item" id="bk-item-${source}-${b.replace(/[^a-z0-9]/gi,'_')}">
<span class="backup-name">${b}</span>
<div class="backup-actions">
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('${source}','${b}',this)"><i class="fas fa-shield-check"></i> Audit</button>
<button class="btn btn-ghost btn-sm" onclick="quickRestore('${source}','${b}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('${source}','${b}',this)"><i class="fas fa-trash"></i></button>
</div>
</div>`).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 = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
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 = `<div class="empty-state" style="color:var(--red)">Audit failed: ${escapeHtml(e)}</div>
<div class="audit-footer"><button type="button" class="btn btn-ghost btn-sm" onclick="closeAuditModal()">Close</button></div>`;
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-shield-check"></i> 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 = '<i class="fas fa-spinner fa-spin"></i>'; }
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 = '<i class="fas fa-trash"></i>'; }
}
}
async function runManualBackup() {
const btn = el('manual-backup-btn');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-play"></i> 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 = '<i class="fas fa-play"></i> 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 = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>';
try {
const r = await fetch('/api/backups/log?limit=15');
const d = await r.json();
if (!d.entries || !d.entries.length) {
node.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i>No backup history yet</div>';
return;
}
node.innerHTML = d.entries.map((e) => `<div class="backup-item"><span class="backup-name">${e.name}</span><span class="card-meta">${e.status}</span></div>`).join('');
} catch (e) {
node.innerHTML = `<div class="empty-state">Error loading log: ${e}</div>`;
}
}
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 = '<div class="empty-state" style="grid-column:1/-1"><i class="fas fa-user-slash"></i>No users</div>';
return;
}
grid.innerHTML = users.map((u, i) => `<div class="user-card" onclick="loadUserContainers('${u.name}')"><div class="user-card-top"><div class="user-avatar" style="background:${userColors[i % userColors.length]}">${u.name[0].toUpperCase()}</div><div><div class="user-name">${u.name}</div><div class="user-uid">uid ${u.uid}</div></div></div><div class="user-tags">${u.has_docker ? '<span class="user-tag docker"><i class="fab fa-docker"></i> docker</span>' : ''}${u.linger ? '<span class="user-tag linger">linger</span>' : ''}${u.has_vdisk ? '<span class="user-tag" style="background:rgba(34,211,238,0.12);color:var(--cyan)">💾 vdisk</span>' : ''}</div><div class="user-stats"><div class="user-stat">Disk: <strong>${u.disk_used}</strong></div><div class="user-stat">Ctrs: <strong>${u.container_count}</strong></div></div></div>`).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 = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i></div></td></tr>';
try {
const r = await fetch(`/api/users/${username}/containers`);
const ctrs = await r.json();
if (!ctrs.length) {
body.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
return;
}
body.innerHTML = ctrs.map((c) => `<tr><td class="ct-name">${c.name}</td><td>${c.status.includes('Up') ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td><td class="ct-image">${c.image}</td><td class="ct-ports">${c.ports || '—'}</td></tr>`).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);
});

View File

@@ -24,28 +24,28 @@
<nav class="nav"> <nav class="nav">
<div class="nav-section-label">MONITOR</div> <div class="nav-section-label">MONITOR</div>
<a class="nav-item active" data-page="dashboard" href="#"> <a class="nav-item {% if active_page == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
<i class="fas fa-gauge-high"></i><span>Dashboard</span> <i class="fas fa-gauge-high"></i><span>Dashboard</span>
</a> </a>
<a class="nav-item" data-page="containers" href="#"> <a class="nav-item {% if active_page == 'containers' %}active{% endif %}" href="{{ url_for('containers_page') }}">
<i class="fas fa-cubes"></i><span>All Containers</span> <i class="fas fa-cubes"></i><span>All Containers</span>
<span class="nav-badge" id="nav-badge-containers"></span> <span class="nav-badge" id="nav-badge-containers"></span>
</a> </a>
<div class="nav-section-label" style="margin-top:20px;">OPERATIONS</div> <div class="nav-section-label" style="margin-top:20px;">OPERATIONS</div>
<a class="nav-item" data-page="restore" href="#"> <a class="nav-item {% if active_page == 'restore' %}active{% endif %}" href="{{ url_for('restore_page') }}">
<i class="fas fa-rotate-right"></i><span>Restore</span> <i class="fas fa-rotate-right"></i><span>Restore</span>
</a> </a>
<a class="nav-item" data-page="backups" href="#"> <a class="nav-item {% if active_page == 'backups' %}active{% endif %}" href="{{ url_for('backups_page') }}">
<i class="fas fa-database"></i><span>Backups</span> <i class="fas fa-database"></i><span>Backups</span>
</a> </a>
<div class="nav-section-label" style="margin-top:20px;">ADMIN</div> <div class="nav-section-label" style="margin-top:20px;">ADMIN</div>
<a class="nav-item" data-page="users" href="#"> <a class="nav-item {% if active_page == 'users' %}active{% endif %}" href="{{ url_for('users_page') }}">
<i class="fas fa-users-gear"></i><span>Users</span> <i class="fas fa-users-gear"></i><span>Users</span>
<span class="nav-badge" id="nav-badge-users"></span> <span class="nav-badge" id="nav-badge-users"></span>
</a> </a>
<a class="nav-item" data-page="settings" href="#"> <a class="nav-item {% if active_page == 'settings' %}active{% endif %}" href="{{ url_for('settings_page') }}">
<i class="fas fa-sliders"></i><span>Settings</span> <i class="fas fa-sliders"></i><span>Settings</span>
</a> </a>
</nav> </nav>
@@ -67,8 +67,8 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<h1 class="page-title" id="page-title">Dashboard</h1> <h1 class="page-title" id="page-title">{{ page_title or 'Dashboard' }}</h1>
<span class="page-subtitle" id="page-subtitle">{{ main_server }}</span> <span class="page-subtitle" id="page-subtitle">{{ page_subtitle or main_server }}</span>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<button class="icon-btn" onclick="refreshAll()" title="Refresh"> <button class="icon-btn" onclick="refreshAll()" title="Refresh">
@@ -84,7 +84,7 @@
</main> </main>
</div> </div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/platform.js') }}?v=navbadges2"></script>
<script> <script>
// ── Theme persistence ─────────────────────────────────────── // ── Theme persistence ───────────────────────────────────────
(function() { (function() {

View File

@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
</div>
<div style="padding:4px 0 12px;">
<p style="color:var(--text2);font-size:13px;margin-bottom:14px;">Manually trigger a backup of all containers</p>
<button class="btn btn-primary" onclick="runManualBackup()" id="manual-backup-btn">
<i class="fas fa-play"></i> Run Backup Now
</button>
</div>
<div id="manual-backup-wrapper" style="display:none; margin-top:16px;">
<div class="card-header" style="margin-bottom:8px;">
<div class="card-title" style="font-size:13px;"><i class="fas fa-terminal"></i> Backup Log</div>
<span class="badge" id="manual-backup-badge" style="background:rgba(59,130,246,0.15);color:var(--accent2);">Running…</span>
</div>
<div id="manual-backup-log" class="log-console" style="max-height:240px;"></div>
<div style="color:var(--text3);font-size:11px;margin-top:6px;font-family:var(--mono);" id="manual-backup-elapsed"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-database"></i> Available Backups</div>
<button class="btn btn-ghost btn-sm" onclick="refreshBackupsList()"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
<div class="two-col">
<div>
<div class="section-header"><div class="section-title">🖥️ MAIN SERVER</div><span class="card-meta">/root/backups/</span></div>
<div class="backup-list" id="local-backup-list">
{% for b in backups %}
<div class="backup-item"><span class="backup-name">{{ b }}</span><div class="backup-actions"><button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button><button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button><button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b }}',this)"><i class="fas fa-trash"></i></button></div></div>
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>{% endfor %}
</div>
</div>
<div>
<div class="section-header"><div class="section-title">💾 VM SERVER</div><span class="card-meta">/backups/main-server/</span></div>
<div class="backup-list" id="vm-backup-list">
{% for b in vm_backups %}
<div class="backup-item"><span class="backup-name">{{ b }}</span><div class="backup-actions"><button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button><button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button><button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b }}',this)"><i class="fas fa-trash"></i></button></div></div>
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>{% endfor %}
</div>
</div>
</div>
</div>
<div id="audit-modal" class="modal-overlay" style="display:none;" onclick="closeAuditModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<div class="modal-title"><i class="fas fa-shield-halved" style="color:var(--cyan);"></i> Backup Health Audit</div>
<button class="modal-close" onclick="closeAuditModal()" title="Close"></button>
</div>
<div id="audit-modal-content"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div></div>
</div>
</div>
<div class="card" style="margin-top:0;">
<div class="card-header">
<div class="card-title"><i class="fas fa-clock-rotate-left"></i> Backup History</div>
<button class="btn btn-ghost btn-sm" onclick="loadBackupLog()"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
<div id="backup-history-list"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-layer-group"></i> All Containers</div>
<div style="display:flex;gap:8px;align-items:center;">
<span class="card-meta" id="all-ctr-meta"></span>
<button class="btn btn-ghost btn-sm" onclick="toggleExtraColumns('all')" id="all-toggle-btn">
<i class="fas fa-eye"></i> Show more
</button>
<button class="btn btn-ghost btn-sm" onclick="loadAllContainers()"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
</div>
<div style="overflow-x:auto;">
<table class="ct-table">
<thead>
<tr>
<th>NAME</th>
<th>OWNER</th>
<th>STATUS</th>
<th>CPU</th>
<th>MEMORY</th>
<th>NET I/O</th>
<th class="col-extra all-extra" style="display:none;">DISK I/O</th>
<th class="col-extra all-extra" style="display:none;">IMAGE</th>
<th class="col-extra all-extra" style="display:none;">PORTS</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody id="all-containers-body">
<tr><td colspan="10"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block content %}
{% macro ctr_actions(name) %}
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('{{ name }}','restart',this)">
<i class="fas fa-rotate-right"></i>
</button>
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('{{ name }}','stop',this)">
<i class="fas fa-stop"></i>
</button>
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('{{ name }}','start',this)">
<i class="fas fa-play"></i>
</button>
{% endmacro %}
<div class="metrics-row">
<div class="metric-card cpu">
<div class="metric-label">CPU USAGE</div>
<div class="metric-value" id="m-cpu">{{ system.cpu_pct }}<span>%</span></div>
<div class="gauge-bar"><div class="gauge-fill" id="g-cpu" style="width:{{ system.cpu_pct }}%"></div></div>
</div>
<div class="metric-card mem">
<div class="metric-label">MEMORY</div>
<div class="metric-value" id="m-mem" style="font-size:16px;">{{ system.memory }}</div>
<div class="gauge-bar"><div class="gauge-fill" id="g-mem" style="width:{{ system.mem_pct }}%"></div></div>
</div>
<div class="metric-card disk">
<div class="metric-label">DISK /</div>
<div class="metric-value" id="m-disk" style="font-size:16px;">{{ system.disk }}</div>
<div class="gauge-bar"><div class="gauge-fill" id="g-disk" style="width:{{ system.disk_pct }}%"></div></div>
</div>
<div class="metric-card load">
<div class="metric-label">LOAD AVG</div>
<div class="metric-value" id="m-load" style="font-size:16px;">{{ system.load }}</div>
<div class="gauge-bar"><div class="gauge-fill" id="g-load" style="width:10%"></div></div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-chart-line"></i> Overview</div>
<span class="card-meta">Docker {{ system.docker_v }} · {{ main_server }}</span>
</div>
<div class="stat-row">
<div class="stat-card"><div class="stat-number" id="stat-total">{{ containers|length }}</div><div class="stat-label">App Containers</div></div>
<div class="stat-card"><div class="stat-number" id="stat-running">{{ running_count }}</div><div class="stat-label">Running</div></div>
<div class="stat-card"><div class="stat-number" id="stat-users">{{ users|length }}</div><div class="stat-label">Linux Users</div></div>
<div class="stat-card"><div class="stat-number" id="stat-local-bk">{{ backups|length }}</div><div class="stat-label">Local Backups</div></div>
<div class="stat-card"><div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div><div class="stat-label">VM Backups</div></div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-cubes"></i> App Containers</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="card-meta">Auto-refresh 15s</span>
<button class="btn btn-ghost btn-sm" onclick="toggleExtraColumns('app')" id="app-toggle-btn">
<i class="fas fa-eye"></i> Show more
</button>
</div>
</div>
<div style="overflow-x:auto;">
<table class="ct-table" id="app-containers-table">
<thead>
<tr>
<th>NAME</th>
<th>STATUS</th>
<th>CPU</th>
<th>MEMORY</th>
<th>NET I/O</th>
<th class="col-extra app-extra" style="display:none;">DISK I/O</th>
<th class="col-extra app-extra" style="display:none;">IMAGE</th>
<th class="col-extra app-extra" style="display:none;">PORTS</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody id="app-containers-body">
{% for c in containers %}
<tr data-ctr="{{ c.name }}">
<td class="ct-name">{{ c.name }}</td>
<td class="ctr-status-cell" data-ctr="{{ c.name }}">
{% if 'Up' in c.status %}
<span class="badge badge-run">Running</span>
{% else %}
<span class="badge badge-stop">Stopped</span>
{% endif %}
</td>
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="cpu"></span></td>
<td>
<div class="stat-bar-wrap">
<div class="stat-bar-bg"><div class="stat-bar-fill" data-ctr="{{ c.name }}" data-stat="mem_bar" style="width:0%"></div></div>
<span class="stat-pct" data-ctr="{{ c.name }}" data-stat="mem_pct"></span>
</div>
</td>
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="net" style="color:var(--cyan)"></span></td>
<td class="col-extra app-extra" style="display:none;"><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="block" style="color:var(--yellow)"></span></td>
<td class="col-extra app-extra ct-image" style="display:none;">{{ c.image }}</td>
<td class="col-extra app-extra ct-ports" style="display:none;">{{ c.ports or '—' }}</td>
<td><div class="action-btns">{{ ctr_actions(c.name) }}</div></td>
</tr>
{% else %}
<tr><td colspan="9"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-rotate-right"></i> Restore Configuration</div>
</div>
<div class="restore-form">
<div class="form-section">
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
<div class="radio-group">
<label class="radio-card">
<input type="radio" name="backup_source" value="local" checked onchange="updateBackupList()">
<div class="radio-body"><span class="radio-icon">🖥️</span><div><div class="radio-label">Main Server</div><div class="radio-desc"><code>/root/backups/</code></div></div></div>
</label>
<label class="radio-card">
<input type="radio" name="backup_source" value="vm" onchange="updateBackupList()">
<div class="radio-body"><span class="radio-icon">💾</span><div><div class="radio-label">VM Backup Server</div><div class="radio-desc"><code>/backups/main-server/</code></div></div></div>
</label>
</div>
<div class="form-group" style="margin-top:14px; max-width:500px;">
<label class="form-label">BACKUP FILE</label>
<select id="backup-file-select" class="form-input">
<optgroup label="Main Server" id="local-options">
{% for b in backups %}<option value="{{ b }}" data-source="local">{{ b }}</option>{% else %}<option disabled>No local backups</option>{% endfor %}
</optgroup>
<optgroup label="VM Backups" id="vm-options" style="display:none;">
{% for b in vm_backups %}<option value="{{ b }}" data-source="vm">{{ b }}</option>{% else %}<option disabled>No VM backups</option>{% endfor %}
</optgroup>
</select>
</div>
</div>
<div class="form-section">
<div class="form-section-title">STEP 2 — SELECT RESTORE TARGET</div>
<div class="radio-group">
<label class="radio-card">
<input type="radio" name="restore_target" value="local" checked onchange="toggleRemoteFields()">
<div class="radio-body"><span class="radio-icon">🎯</span><div><div class="radio-label">Restore on This Server</div><div class="radio-desc" id="this-server-desc">Loading hostname…</div></div></div>
</label>
<label class="radio-card">
<input type="radio" name="restore_target" value="remote" onchange="toggleRemoteFields()">
<div class="radio-body"><span class="radio-icon">📡</span><div><div class="radio-label">External Machine</div><div class="radio-desc">via SSH — any IP</div></div></div>
</label>
</div>
<div id="remote-fields" style="display:none; margin-top:16px; max-width:560px;">
<div class="form-row">
<div class="form-group">
<label class="form-label">TARGET IP</label>
<input type="text" id="remote-ip" class="form-input" placeholder="192.168.x.x or IP">
</div>
<div class="form-group" style="max-width:100px;">
<label class="form-label">SSH PORT</label>
<input type="text" id="remote-port" class="form-input" value="22">
</div>
<div class="form-group" style="max-width:120px;">
<label class="form-label">SSH USER</label>
<input type="text" id="remote-user" class="form-input" value="root">
</div>
</div>
<div class="form-group" style="margin-top:8px;">
<label class="form-label">AUTHENTICATION</label>
<div class="radio-group" style="gap:8px;">
<label class="radio-card small">
<input type="radio" name="auth_method" value="key" checked onchange="toggleAuthFields()">
<div class="radio-body"><span>🔑</span><div class="radio-label">SSH Key</div></div>
</label>
<label class="radio-card small">
<input type="radio" name="auth_method" value="password" onchange="toggleAuthFields()">
<div class="radio-body"><span>🔒</span><div class="radio-label">Password</div></div>
</label>
</div>
</div>
<div id="key-field" class="form-group" style="margin-top:8px;">
<label class="form-label">SSH KEY PATH</label>
<input type="text" id="ssh-key-path" class="form-input" value="/root/.ssh/contabo-key">
</div>
<div id="password-field" class="form-group" style="display:none; margin-top:8px;">
<label class="form-label">SSH PASSWORD</label>
<input type="password" id="ssh-password" class="form-input">
</div>
</div>
</div>
<div class="form-section">
<button class="btn btn-danger btn-lg" onclick="launchRestore()" id="restore-btn">
<i class="fas fa-play"></i> Start Restore
</button>
<p style="color:var(--text3); font-size:12px; margin-top:8px;">⚠ Healthy running containers are skipped.</p>
</div>
</div>
<div id="restore-log-wrapper" style="display:none; margin-top:20px;">
<div class="card-header" style="margin-bottom:10px;">
<div class="card-title"><i class="fas fa-terminal"></i> Restore Log</div>
<span class="badge" id="restore-status-badge" style="background:rgba(59,130,246,0.15);color:var(--accent2);">Running…</span>
</div>
<div id="restore-log" class="log-console"></div>
<div style="color:var(--text3);font-size:11px;margin-top:6px;font-family:var(--mono);" id="restore-elapsed"></div>
</div>
</div>
<script>
window.restorePrefill = {{ restore_prefill|tojson }};
</script>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<div class="card-header"><div class="card-title"><i class="fas fa-sliders"></i> Platform Settings</div></div>
<div style="max-width:460px; display:flex; flex-direction:column; gap:14px;">
<div class="form-group"><label class="form-label">MAIN SERVER IP</label><input class="form-input" value="{{ main_server }}" readonly></div>
<div class="form-group"><label class="form-label">PLATFORM HOST</label><input class="form-input" value="{{ 'Main Server' if running_on_main else 'VM (backup mode)' }}" readonly></div>
<div class="form-group"><label class="form-label">VM BACKUP PATH</label><input class="form-input" value="/backups/main-server/" readonly></div>
<div class="form-group"><label class="form-label">MAIN SERVER UPTIME</label><input class="form-input" id="settings-uptime" value="{{ system.uptime }}" readonly></div>
<div class="form-group"><label class="form-label">DOCKER VERSION</label><input class="form-input" value="{{ system.docker_v }}" readonly></div>
<button class="btn btn-ghost" onclick="refreshAll()"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<div class="card-header"><div class="card-title"><i class="fas fa-user-plus"></i> Create New User</div></div>
<div class="form-grid">
<div class="form-group"><label class="form-label">USERNAME</label><input type="text" id="new-username" class="form-input" placeholder="e.g. secuser2"></div>
<div class="form-group"><label class="form-label">PASSWORD (optional)</label><input type="password" id="new-password" class="form-input"></div>
<div class="form-group"><label class="form-label">DISK SIZE MB (creates dedicated virtual disk)</label><input type="number" id="new-quota" class="form-input" placeholder="e.g. 10240 for 10GB"></div>
<div class="form-group" style="justify-content:flex-end;">
<label class="form-check" style="margin-bottom:8px;"><input type="checkbox" id="new-docker" checked><span>Setup rootless Docker</span></label>
<button class="btn btn-primary" onclick="createUser()"><i class="fas fa-plus"></i> Create User</button>
</div>
</div>
<div id="create-user-result" class="alert" style="margin-top:14px;"></div>
<div id="create-user-log" class="log-console" style="display:none; margin-top:12px; max-height:200px;"></div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-users"></i> System Users</div>
<button class="btn btn-ghost btn-sm" onclick="loadUsers()"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
<div class="users-grid" id="users-grid">
{% for u in users %}
<div class="user-card" onclick="loadUserContainers('{{ u.name }}')">
<div class="user-card-top"><div class="user-avatar" style="background:#3b82f6">{{ u.name[0].upper() }}</div><div><div class="user-name">{{ u.name }}</div><div class="user-uid">uid {{ u.uid }}</div></div></div>
<div class="user-tags">{% if u.has_docker %}<span class="user-tag docker"><i class="fab fa-docker"></i> docker</span>{% endif %}{% if u.linger %}<span class="user-tag linger">linger</span>{% endif %}{% if u.has_vdisk %}<span class="user-tag" style="background:rgba(34,211,238,0.12);color:var(--cyan);">💾 vdisk</span>{% endif %}</div>
<div class="user-stats"><div class="user-stat">Disk: <strong>{{ u.disk_used }}</strong></div><div class="user-stat">Ctrs: <strong>{{ u.container_count }}</strong></div></div>
</div>
{% else %}
<div class="empty-state" style="grid-column:1/-1"><i class="fas fa-user-slash"></i>No non-system users</div>
{% endfor %}
</div>
</div>
<div class="card" id="user-detail-panel" style="display:none;">
<div class="card-header">
<div class="card-title"><i class="fas fa-cube"></i> <span id="user-detail-title">Containers</span></div>
<div style="display:flex;gap:8px;"><button class="btn btn-ghost btn-sm" style="color:var(--red);" onclick="deleteUser()"><i class="fas fa-trash"></i> Delete</button><button class="btn btn-ghost btn-sm" onclick="document.getElementById('user-detail-panel').style.display='none'"></button></div>
</div>
<div style="overflow-x:auto;"><table class="ct-table"><thead><tr><th>NAME</th><th>STATUS</th><th>IMAGE</th><th>PORTS</th></tr></thead><tbody id="user-containers-body"></tbody></table></div>
<div id="user-action-result" class="alert" style="margin-top:14px;"></div>
</div>
{% endblock %}

View File

@@ -19,12 +19,25 @@ VM_PORT="2223"
VM_KEY="/root/.ssh/contabo-key" VM_KEY="/root/.ssh/contabo-key"
VM_DEST="/backups/main-server/" VM_DEST="/backups/main-server/"
# 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 "========================================="
echo "📦 Starting Backup: $BACKUP_NAME" echo "📦 Starting Backup: $BACKUP_NAME"
echo " Apps: Frappe, Nextcloud, Mautic, n8n, Odoo" echo " Apps: Frappe, Nextcloud, Mautic, n8n, Odoo"
echo "=========================================" echo "========================================="
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
mkdir -p "/root/backups"
cd "$BACKUP_DIR" cd "$BACKUP_DIR"
# -------------------------------------------------- # --------------------------------------------------
@@ -77,7 +90,6 @@ VOLUMES=(
) )
for volume in "${VOLUMES[@]}"; do for volume in "${VOLUMES[@]}"; do
# Skip volumes that don't exist on this host
if ! docker volume inspect "$volume" &>/dev/null; then if ! docker volume inspect "$volume" &>/dev/null; then
echo " ⏭️ $volume — not found, skipping" echo " ⏭️ $volume — not found, skipping"
continue continue
@@ -122,7 +134,6 @@ docker run --rm \
&& echo " ✅ Nextcloud config.php" \ && echo " ✅ Nextcloud config.php" \
|| echo " ⏭️ Nextcloud config not found" || echo " ⏭️ Nextcloud config not found"
# Frappe site config
docker exec frappe-erpnext \ docker exec frappe-erpnext \
cat /home/frappe/frappe-bench/sites/erpnext.navitrends.ovh/site_config.json \ cat /home/frappe/frappe-bench/sites/erpnext.navitrends.ovh/site_config.json \
> configs/frappe-site_config.json 2>/dev/null \ > configs/frappe-site_config.json 2>/dev/null \
@@ -130,7 +141,7 @@ docker exec frappe-erpnext \
|| echo " ⏭️ Frappe config not found" || echo " ⏭️ Frappe config not found"
# -------------------------------------------------- # --------------------------------------------------
# 6. Backup metadata # 6. Backup metadata + checksum
# -------------------------------------------------- # --------------------------------------------------
echo "" echo ""
echo "📝 [6/7] Writing backup metadata..." echo "📝 [6/7] Writing backup metadata..."
@@ -146,6 +157,14 @@ Docker info: $(docker --version)
Volumes included: Volumes included:
$(ls volumes/*.tar.gz 2>/dev/null | xargs -I{} basename {} || echo "none") $(ls volumes/*.tar.gz 2>/dev/null | xargs -I{} basename {} || echo "none")
EOF EOF
# Write individual volume checksums for integrity verification later
echo "" >> backup-info.txt
echo "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" echo " ✅ Done"
# -------------------------------------------------- # --------------------------------------------------
@@ -158,8 +177,35 @@ tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}/"
COMPRESSED_SIZE=$(du -h "${BACKUP_NAME}.tar.gz" | cut -f1) COMPRESSED_SIZE=$(du -h "${BACKUP_NAME}.tar.gz" | cut -f1)
echo " ✅ Compressed size: $COMPRESSED_SIZE$BACKUP_ARCHIVE" echo " ✅ Compressed size: $COMPRESSED_SIZE$BACKUP_ARCHIVE"
# Write a top-level SHA256 for the final archive (used by health-check)
sha256sum "${BACKUP_NAME}.tar.gz" > "${BACKUP_NAME}.tar.gz.sha256"
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 ""
echo "📤 Sending backup to VM (${VM_HOST}:${VM_PORT})..." echo "📤 Sending backup to VM (${VM_HOST}:${VM_PORT})..."
@@ -172,7 +218,9 @@ scp -i "$VM_KEY" \
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo " ✅ Backup sent to VM successfully!" 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 else
echo " ❌ Transfer failed. The compressed backup is still at:" echo " ❌ Transfer failed. The compressed backup is still at:"
echo " $BACKUP_ARCHIVE" echo " $BACKUP_ARCHIVE"
@@ -180,6 +228,11 @@ else
echo " scp -i $VM_KEY -P $VM_PORT $BACKUP_ARCHIVE ${VM_USER}@${VM_HOST}:${VM_DEST}" echo " scp -i $VM_KEY -P $VM_PORT $BACKUP_ARCHIVE ${VM_USER}@${VM_HOST}:${VM_DEST}"
fi fi
# --------------------------------------------------
# 10. Write final status to log
# --------------------------------------------------
log_status "SUCCESS" "$BACKUP_NAME" "size=${COMPRESSED_SIZE}"
echo "" echo ""
echo "=========================================" echo "========================================="
echo "✅ BACKUP COMPLETE" echo "✅ BACKUP COMPLETE"