Sync from main server - 2026-04-18 18:47:38
This commit is contained in:
249
platform/app.py
249
platform/app.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
634
platform/static/js/platform.js
Normal file
634
platform/static/js/platform.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditScoreBlockTierClass(tier) {
|
||||||
|
const t = String(tier || 'fair');
|
||||||
|
if (t === 'excellent' || t === 'good') return 'audit-tier-good';
|
||||||
|
if (t === 'fair') return 'audit-tier-fair';
|
||||||
|
if (t === 'poor') return 'audit-tier-poor';
|
||||||
|
return 'audit-tier-critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditModalContent(d, filename) {
|
||||||
|
if (d.error) {
|
||||||
|
return `<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);
|
||||||
|
});
|
||||||
@@ -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() {
|
||||||
|
|||||||
65
platform/templates/pages/backups.html
Normal file
65
platform/templates/pages/backups.html
Normal 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 %}
|
||||||
36
platform/templates/pages/containers.html
Normal file
36
platform/templates/pages/containers.html
Normal 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 %}
|
||||||
108
platform/templates/pages/dashboard.html
Normal file
108
platform/templates/pages/dashboard.html
Normal 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 %}
|
||||||
106
platform/templates/pages/restore.html
Normal file
106
platform/templates/pages/restore.html
Normal 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 %}
|
||||||
14
platform/templates/pages/settings.html
Normal file
14
platform/templates/pages/settings.html
Normal 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 %}
|
||||||
44
platform/templates/pages/users.html
Normal file
44
platform/templates/pages/users.html
Normal 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 %}
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user