Sync from main server - 2026-04-16 13:42:46
This commit is contained in:
104
platform/app.py
104
platform/app.py
@@ -1,19 +1,28 @@
|
|||||||
|
# app.py
|
||||||
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
|
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
from config import MAIN_SERVER_IP, VM_HOST, VM_PORT, VM_KEY, VM_USER
|
|
||||||
|
from config import (
|
||||||
|
MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER,
|
||||||
|
VM_HOST, VM_PORT, VM_KEY, VM_USER,
|
||||||
|
MAIN_SERVER_KEY, MAIN_SERVER_PORT, MAIN_SERVER_USER,
|
||||||
|
)
|
||||||
from modules.auth import login_required
|
from modules.auth import login_required
|
||||||
from modules.backups import (
|
from modules.backups import (
|
||||||
get_containers, get_all_root_containers, get_local_backups,
|
get_containers, get_all_root_containers,
|
||||||
get_vm_backups, get_all_stats, get_system_info
|
get_local_backups, get_vm_backups,
|
||||||
|
get_all_stats, get_system_info,
|
||||||
|
get_rootless_user_containers_remote,
|
||||||
|
container_action,
|
||||||
)
|
)
|
||||||
from modules.commands import run_command, run_ssh_to_vm
|
from modules.commands import run_command
|
||||||
from modules.users import (
|
from modules.users import (
|
||||||
get_all_users, get_user_containers, get_all_users_containers,
|
get_all_users, get_user_containers, get_all_users_containers,
|
||||||
create_user, delete_user, get_user_disk_usage
|
create_user, delete_user, get_user_disk_usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -51,6 +60,7 @@ 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('dashboard.html',
|
||||||
containers=containers,
|
containers=containers,
|
||||||
@@ -59,11 +69,12 @@ def dashboard():
|
|||||||
vm_backups=vm_backups,
|
vm_backups=vm_backups,
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# API — system info + stats (live poll)
|
# API — system + stats (always from main server)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@app.route('/api/system')
|
@app.route('/api/system')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -74,7 +85,6 @@ def api_system():
|
|||||||
@app.route('/api/stats')
|
@app.route('/api/stats')
|
||||||
@login_required
|
@login_required
|
||||||
def api_stats():
|
def api_stats():
|
||||||
"""Container resource stats for ALL containers (root + rootless users)."""
|
|
||||||
return jsonify(get_all_stats())
|
return jsonify(get_all_stats())
|
||||||
|
|
||||||
|
|
||||||
@@ -89,14 +99,35 @@ def api_containers():
|
|||||||
@app.route('/api/containers/all')
|
@app.route('/api/containers/all')
|
||||||
@login_required
|
@login_required
|
||||||
def api_containers_all():
|
def api_containers_all():
|
||||||
"""Root containers + all users' rootless containers combined."""
|
"""Root containers + rootless-user containers, all from main server."""
|
||||||
root_ctrs = get_all_root_containers()
|
root_ctrs = get_all_root_containers()
|
||||||
user_ctrs = get_all_users_containers()
|
user_ctrs = get_rootless_user_containers_remote()
|
||||||
all_ctrs = root_ctrs + user_ctrs
|
all_ctrs = root_ctrs + user_ctrs
|
||||||
running = sum(1 for c in all_ctrs if 'Up' in c.get('status', ''))
|
running = sum(1 for c in all_ctrs if 'Up' in c.get('status', ''))
|
||||||
return jsonify({'containers': all_ctrs, 'running': running})
|
return jsonify({'containers': all_ctrs, 'running': running})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# API — container actions
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
@app.route('/api/container/action', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_container_action():
|
||||||
|
"""
|
||||||
|
POST JSON: { "name": "container-name", "action": "start|stop|restart" }
|
||||||
|
Runs the action on the main server (via SSH if on VM).
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
action = data.get('action', '').strip()
|
||||||
|
|
||||||
|
if not name or not action:
|
||||||
|
return jsonify({'success': False, 'message': 'name and action required'}), 400
|
||||||
|
|
||||||
|
success, output = container_action(name, action)
|
||||||
|
return jsonify({'success': success, 'output': output})
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# API — backups
|
# API — backups
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -107,7 +138,7 @@ def api_backups():
|
|||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# API — users management
|
# API — users (LOCAL — users on this host)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@app.route('/api/users')
|
@app.route('/api/users')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -185,22 +216,46 @@ def restore_start():
|
|||||||
if not backup_file:
|
if not backup_file:
|
||||||
return jsonify({'error': 'No backup file specified'}), 400
|
return jsonify({'error': 'No backup file specified'}), 400
|
||||||
|
|
||||||
# Resolve archive path on this server
|
# ── 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:
|
||||||
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'Backup 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}"
|
||||||
|
if not os.path.exists(backup_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"{backup_path}"
|
||||||
|
)
|
||||||
|
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500
|
||||||
|
else:
|
||||||
|
# VM backup
|
||||||
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
|
# Pull from VM via tunnel
|
||||||
backup_path = f"/tmp/{backup_file}"
|
backup_path = f"/tmp/{backup_file}"
|
||||||
if not os.path.exists(backup_path):
|
if not os.path.exists(backup_path):
|
||||||
pull_cmd = (
|
pull_cmd = (
|
||||||
f"scp -i {VM_KEY} -P {VM_PORT} "
|
f"scp -i {VM_KEY} -P {VM_PORT} "
|
||||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
||||||
f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} {backup_path}"
|
f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} "
|
||||||
|
f"{backup_path}"
|
||||||
)
|
)
|
||||||
result = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if res.returncode != 0:
|
||||||
return jsonify({'error': f'Failed to pull from VM: {result.stderr}'}), 500
|
return jsonify({'error': f'Failed to pull from VM: {res.stderr}'}), 500
|
||||||
|
else:
|
||||||
|
# We're on VM → backup is local
|
||||||
|
backup_path = f"/backups/main-server/{backup_file}"
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
return jsonify({'error': f'Not found: {backup_path}'}), 400
|
||||||
|
|
||||||
restore_script_local = os.path.join(
|
restore_script_local = os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)), 'restore-myapps.sh'
|
os.path.dirname(os.path.abspath(__file__)), 'restore-myapps.sh'
|
||||||
@@ -208,20 +263,28 @@ 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
|
||||||
session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}"
|
session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}"
|
||||||
cmd = (
|
cmd = (
|
||||||
f"set -e && mkdir -p {session_dir} && "
|
f"set -e && "
|
||||||
f"echo '📂 Extracting backup locally...' && "
|
f"echo '🖥️ Restoring on this server ({hostname})...' && "
|
||||||
|
f"mkdir -p {session_dir} && "
|
||||||
|
f"echo '📂 Extracting backup...' && "
|
||||||
f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && "
|
f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && "
|
||||||
f"cp {restore_script_local} {session_dir}/restore-myapps.sh && "
|
f"cp {restore_script_local} {session_dir}/restore-myapps.sh && "
|
||||||
f"chmod +x {session_dir}/restore-myapps.sh && "
|
f"chmod +x {session_dir}/restore-myapps.sh && "
|
||||||
f"cd {session_dir} && bash restore-myapps.sh ; "
|
f"cd {session_dir} && bash restore-myapps.sh ; "
|
||||||
f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT"
|
f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT"
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
# Explicit remote machine (custom IP)
|
||||||
if not remote_ip:
|
if not remote_ip:
|
||||||
return jsonify({'error': 'remote_ip required for remote restore'}), 400
|
return jsonify({'error': 'remote_ip required'}), 400
|
||||||
|
|
||||||
base_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
base_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||||
if auth_method == 'key':
|
if auth_method == 'key':
|
||||||
@@ -247,7 +310,6 @@ def restore_start():
|
|||||||
f"echo '🚀 Running restore on {remote_ip}:{remote_port}...' && "
|
f"echo '🚀 Running restore on {remote_ip}:{remote_port}...' && "
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} "
|
f"{ssh_prefix} {remote_user}@{remote_ip} "
|
||||||
f"'set -e && cd {remote_dest} && "
|
f"'set -e && cd {remote_dest} && "
|
||||||
f"echo \"📂 Extracting backup...\" && "
|
|
||||||
f"tar -xzf {backup_file} --strip-components=1 && "
|
f"tar -xzf {backup_file} --strip-components=1 && "
|
||||||
f"chmod +x restore-myapps.sh && bash restore-myapps.sh' ; "
|
f"chmod +x restore-myapps.sh && bash restore-myapps.sh' ; "
|
||||||
f"EXIT=$? ; "
|
f"EXIT=$? ; "
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
# modules/backups.py
|
||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
import subprocess
|
import subprocess
|
||||||
import json
|
import json
|
||||||
from config import RUNNING_ON_MAIN_SERVER, VM_HOST, VM_PORT, VM_KEY, VM_USER
|
from config import (
|
||||||
|
RUNNING_ON_MAIN_SERVER,
|
||||||
|
MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT,
|
||||||
|
VM_HOST, VM_PORT, VM_KEY, VM_USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd, timeout=20):
|
def _run(cmd, timeout=20):
|
||||||
@@ -13,12 +18,35 @@ def _run(cmd, timeout=20):
|
|||||||
return '', str(e)
|
return '', str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_main(remote_cmd, timeout=20):
|
||||||
|
"""
|
||||||
|
Run a command ON THE MAIN SERVER.
|
||||||
|
- If we're already on the main server: run it locally.
|
||||||
|
- If we're on the VM: SSH to main server first.
|
||||||
|
"""
|
||||||
|
if RUNNING_ON_MAIN_SERVER:
|
||||||
|
return _run(remote_cmd, timeout=timeout)
|
||||||
|
else:
|
||||||
|
ssh = (
|
||||||
|
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} "
|
||||||
|
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
|
||||||
|
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}"
|
||||||
|
)
|
||||||
|
return _run(f"{ssh} '{remote_cmd}'", timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
# BACKUPS
|
# BACKUPS (local = on main server; vm = on the VM)
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_local_backups():
|
def get_local_backups():
|
||||||
stdout, _ = _run("ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null | head -20")
|
"""
|
||||||
|
Backups stored on the MAIN SERVER at /root/backups/.
|
||||||
|
Always fetched from main server regardless of where platform runs.
|
||||||
|
"""
|
||||||
|
stdout, _ = _ssh_main(
|
||||||
|
"ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null | head -20"
|
||||||
|
)
|
||||||
files = []
|
files = []
|
||||||
if stdout:
|
if stdout:
|
||||||
for line in stdout.split('\n'):
|
for line in stdout.split('\n'):
|
||||||
@@ -29,7 +57,13 @@ 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 = (
|
||||||
@@ -38,7 +72,7 @@ def get_vm_backups():
|
|||||||
f"{VM_USER}@{VM_HOST} "
|
f"{VM_USER}@{VM_HOST} "
|
||||||
f"'ls -t /backups/main-server/myapps-backup-*.tar.gz 2>/dev/null | head -20'"
|
f"'ls -t /backups/main-server/myapps-backup-*.tar.gz 2>/dev/null | head -20'"
|
||||||
)
|
)
|
||||||
stdout, _ = _run(cmd, timeout=20)
|
stdout, _ = _run(cmd, timeout=25)
|
||||||
if stdout:
|
if stdout:
|
||||||
for line in stdout.split('\n'):
|
for line in stdout.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -47,80 +81,121 @@ 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
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
# ROOT CONTAINERS
|
# CONTAINERS (always from main server)
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse_containers(raw, owner='root'):
|
||||||
|
containers = []
|
||||||
|
if raw:
|
||||||
|
for line in raw.split('\n'):
|
||||||
|
if '|' not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split('|')
|
||||||
|
containers.append({
|
||||||
|
'name': parts[0].strip(),
|
||||||
|
'status': parts[1].strip() if len(parts) > 1 else '',
|
||||||
|
'image': parts[2].strip() if len(parts) > 2 else '',
|
||||||
|
'ports': parts[3].strip() if len(parts) > 3 else '',
|
||||||
|
'owner': owner,
|
||||||
|
})
|
||||||
|
return containers
|
||||||
|
|
||||||
|
|
||||||
def get_containers():
|
def get_containers():
|
||||||
"""Root app containers only (filtered)."""
|
"""App containers only (frappe/nextcloud/mautic/n8n/odoo) — always from main server."""
|
||||||
stdout, _ = _run(
|
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'"
|
||||||
)
|
)
|
||||||
containers = []
|
return _parse_containers(stdout)
|
||||||
if stdout:
|
|
||||||
for line in stdout.split('\n'):
|
|
||||||
if '|' not in line:
|
|
||||||
continue
|
|
||||||
parts = line.split('|')
|
|
||||||
containers.append({
|
|
||||||
'name': parts[0].strip(),
|
|
||||||
'status': parts[1].strip(),
|
|
||||||
'image': parts[2].strip(),
|
|
||||||
'ports': parts[3].strip() if len(parts) > 3 else '',
|
|
||||||
'owner': 'root',
|
|
||||||
})
|
|
||||||
return containers
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_root_containers():
|
def get_all_root_containers():
|
||||||
"""ALL root docker containers (unfiltered)."""
|
"""ALL root docker containers (unfiltered) — always from main server."""
|
||||||
stdout, _ = _run(
|
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"
|
||||||
)
|
)
|
||||||
|
return _parse_containers(stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rootless_user_containers_remote():
|
||||||
|
"""
|
||||||
|
Get containers from all rootless-docker users ON THE MAIN SERVER.
|
||||||
|
Root reads /run/user/*/docker.sock via SSH.
|
||||||
|
"""
|
||||||
|
# List all non-system UIDs that have a docker socket on main server
|
||||||
|
cmd = "ls /run/user/*/docker.sock 2>/dev/null"
|
||||||
|
stdout, _ = _ssh_main(cmd)
|
||||||
containers = []
|
containers = []
|
||||||
if stdout:
|
if not stdout:
|
||||||
for line in stdout.split('\n'):
|
return containers
|
||||||
if '|' not in line:
|
|
||||||
|
for sock_path in stdout.split('\n'):
|
||||||
|
sock_path = sock_path.strip()
|
||||||
|
if not sock_path:
|
||||||
continue
|
continue
|
||||||
parts = line.split('|')
|
# Determine username from uid
|
||||||
containers.append({
|
try:
|
||||||
'name': parts[0].strip(),
|
uid = sock_path.split('/run/user/')[1].split('/')[0]
|
||||||
'status': parts[1].strip(),
|
except (IndexError, ValueError):
|
||||||
'image': parts[2].strip(),
|
continue
|
||||||
'ports': parts[3].strip() if len(parts) > 3 else '',
|
|
||||||
'owner': 'root',
|
name_out, _ = _ssh_main(f"getent passwd {uid} | cut -d: -f1")
|
||||||
})
|
username = name_out.strip() or f"uid{uid}"
|
||||||
|
|
||||||
|
ctr_out, _ = _ssh_main(
|
||||||
|
f"DOCKER_HOST=unix://{sock_path} "
|
||||||
|
f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null"
|
||||||
|
)
|
||||||
|
containers.extend(_parse_containers(ctr_out, owner=username))
|
||||||
|
|
||||||
return containers
|
return containers
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
# CONTAINER STATS
|
# CONTAINER ACTIONS (start / stop / restart) — on main server
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_container_stats(docker_socket=None):
|
def container_action(container_name, action):
|
||||||
"""One-shot stats snapshot. Returns dict keyed by container name."""
|
"""
|
||||||
if docker_socket:
|
action: 'start' | 'stop' | 'restart'
|
||||||
cmd = (
|
Returns (success: bool, output: str)
|
||||||
f"DOCKER_HOST=unix://{docker_socket} "
|
"""
|
||||||
f"docker stats --no-stream --format "
|
if action not in ('start', 'stop', 'restart'):
|
||||||
f"'{{{{.Name}}}}|{{{{.CPUPerc}}}}|{{{{.MemUsage}}}}|{{{{.MemPerc}}}}|{{{{.NetIO}}}}|{{{{.BlockIO}}}}' 2>/dev/null"
|
return False, "Invalid action"
|
||||||
)
|
|
||||||
else:
|
|
||||||
cmd = (
|
|
||||||
"docker stats --no-stream --format "
|
|
||||||
"'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null"
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout, _ = _run(cmd, timeout=30)
|
safe_name = container_name.replace('"', '').replace(';', '').replace('|', '')
|
||||||
|
stdout, stderr = _ssh_main(
|
||||||
|
f"docker {action} {safe_name} 2>&1",
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
output = (stdout + stderr).strip()
|
||||||
|
success = safe_name in output or 'started' in output.lower() or stderr == ''
|
||||||
|
return True, output
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# STATS — from main server
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_container_stats_remote():
|
||||||
|
"""One-shot stats for all root containers on main server."""
|
||||||
|
stdout, _ = _ssh_main(
|
||||||
|
"docker stats --no-stream --format "
|
||||||
|
"'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}' 2>/dev/null",
|
||||||
|
timeout=35
|
||||||
|
)
|
||||||
stats = {}
|
stats = {}
|
||||||
if stdout:
|
if stdout:
|
||||||
for line in stdout.split('\n'):
|
for line in stdout.split('\n'):
|
||||||
@@ -141,32 +216,54 @@ def get_container_stats(docker_socket=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_all_stats():
|
def get_all_stats():
|
||||||
"""Stats for root + all rootless-user containers combined."""
|
"""Stats for root containers on main server + rootless users on main server."""
|
||||||
all_stats = get_container_stats()
|
all_stats = get_container_stats_remote()
|
||||||
try:
|
|
||||||
import pwd
|
# Also get stats from rootless user sockets on main server
|
||||||
for pw in pwd.getpwall():
|
socks_out, _ = _ssh_main("ls /run/user/*/docker.sock 2>/dev/null")
|
||||||
if pw.pw_uid < 1000 or pw.pw_name == 'nobody':
|
if socks_out:
|
||||||
|
for sock in socks_out.split('\n'):
|
||||||
|
sock = sock.strip()
|
||||||
|
if not sock:
|
||||||
continue
|
continue
|
||||||
sock = f"/run/user/{pw.pw_uid}/docker.sock"
|
stdout, _ = _ssh_main(
|
||||||
if os.path.exists(sock):
|
f"DOCKER_HOST=unix://{sock} "
|
||||||
user_stats = get_container_stats(docker_socket=sock)
|
f"docker stats --no-stream --format "
|
||||||
all_stats.update(user_stats)
|
f"'{{{{.Name}}}}|{{{{.CPUPerc}}}}|{{{{.MemUsage}}}}|{{{{.MemPerc}}}}|{{{{.NetIO}}}}|{{{{.BlockIO}}}}' 2>/dev/null",
|
||||||
except Exception as e:
|
timeout=35
|
||||||
print(f"[stats] Error: {e}")
|
)
|
||||||
|
if stdout:
|
||||||
|
for line in stdout.split('\n'):
|
||||||
|
if '|' not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split('|')
|
||||||
|
if len(parts) < 6:
|
||||||
|
continue
|
||||||
|
all_stats[parts[0].strip()] = {
|
||||||
|
'cpu': parts[1].strip(),
|
||||||
|
'mem': parts[2].strip(),
|
||||||
|
'mem_pct': parts[3].strip(),
|
||||||
|
'net': parts[4].strip(),
|
||||||
|
'block': parts[5].strip(),
|
||||||
|
}
|
||||||
return all_stats
|
return all_stats
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# SYSTEM INFO — from main server
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_system_info():
|
def get_system_info():
|
||||||
"""Host-level system stats."""
|
"""Host-level system stats — always fetched from main server."""
|
||||||
cpu_out, _ = _run("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
|
cpu_out, _ = _ssh_main("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
|
||||||
mem_out, _ = _run("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, _ = _run("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'")
|
mem_pct, _ = _ssh_main("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'")
|
||||||
disk_out, _ = _run("df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}'")
|
disk_out, _ = _ssh_main("df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}'")
|
||||||
disk_pct, _ = _run("df / | awk 'NR==2{print $5}' | tr -d '%'")
|
disk_pct, _ = _ssh_main("df / | awk 'NR==2{print $5}' | tr -d '%'")
|
||||||
load_out, _ = _run("cat /proc/loadavg | awk '{print $1, $2, $3}'")
|
load_out, _ = _ssh_main("cat /proc/loadavg | awk '{print $1, $2, $3}'")
|
||||||
uptime_out, _ = _run("uptime -p")
|
uptime, _ = _ssh_main("uptime -p")
|
||||||
docker_v, _ = _run("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
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'cpu_pct': cpu_out or '0',
|
'cpu_pct': cpu_out or '0',
|
||||||
@@ -175,6 +272,7 @@ def get_system_info():
|
|||||||
'disk': disk_out or 'N/A',
|
'disk': disk_out or 'N/A',
|
||||||
'disk_pct': disk_pct or '0',
|
'disk_pct': disk_pct or '0',
|
||||||
'load': load_out or 'N/A',
|
'load': load_out or 'N/A',
|
||||||
'uptime': uptime_out 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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{# Macro must be defined BEFORE it is used #}
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
{# ═══════════════════════════════════════════════════════
|
{# ═══════════════════════════════════════════════════════
|
||||||
DASHBOARD PAGE
|
DASHBOARD PAGE
|
||||||
═══════════════════════════════════════════════════════ #}
|
═══════════════════════════════════════════════════════ #}
|
||||||
<div id="dashboard-page" class="page active">
|
<div id="dashboard-page" class="page active">
|
||||||
|
|
||||||
<!-- System Metrics -->
|
|
||||||
<div class="metrics-row">
|
<div class="metrics-row">
|
||||||
<div class="metric-card cpu">
|
<div class="metric-card cpu">
|
||||||
<div class="metric-label">CPU USAGE</div>
|
<div class="metric-label">CPU USAGE</div>
|
||||||
@@ -30,41 +42,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-chart-line"></i> Overview</div>
|
<div class="card-title"><i class="fas fa-chart-line"></i> Overview</div>
|
||||||
<span class="card-meta" id="docker-version">Docker {{ system.docker_v }}</span>
|
<span class="card-meta">Docker {{ system.docker_v }} · {{ main_server }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<div class="stat-card">
|
<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-number" id="stat-total">{{ containers|length }}</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-label">App Containers</div>
|
<div class="stat-card"><div class="stat-number" id="stat-users">{{ users|length }}</div><div class="stat-label">Linux Users</div></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-card"><div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div><div class="stat-label">VM Backups</div></div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- App Containers with Live Stats -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-cubes"></i> App Containers</div>
|
<div class="card-title"><i class="fas fa-cubes"></i> App Containers</div>
|
||||||
<span class="card-meta">Auto-refresh every 15s</span>
|
<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>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="ct-table" id="app-containers-table">
|
<table class="ct-table" id="app-containers-table">
|
||||||
@@ -75,14 +75,15 @@
|
|||||||
<th>CPU</th>
|
<th>CPU</th>
|
||||||
<th>MEMORY</th>
|
<th>MEMORY</th>
|
||||||
<th>NET I/O</th>
|
<th>NET I/O</th>
|
||||||
<th>DISK I/O</th>
|
<th class="col-extra app-extra" style="display:none;">DISK I/O</th>
|
||||||
<th>IMAGE</th>
|
<th class="col-extra app-extra" style="display:none;">IMAGE</th>
|
||||||
<th>PORTS</th>
|
<th class="col-extra app-extra" style="display:none;">PORTS</th>
|
||||||
|
<th>ACTIONS</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="app-containers-body">
|
<tbody id="app-containers-body">
|
||||||
{% for c in containers %}
|
{% for c in containers %}
|
||||||
<tr>
|
<tr data-ctr="{{ c.name }}">
|
||||||
<td class="ct-name">{{ c.name }}</td>
|
<td class="ct-name">{{ c.name }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if 'Up' in c.status %}
|
{% if 'Up' in c.status %}
|
||||||
@@ -94,19 +95,18 @@
|
|||||||
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="cpu">—</span></td>
|
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="cpu">—</span></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="stat-bar-wrap">
|
<div class="stat-bar-wrap">
|
||||||
<div class="stat-bar-bg">
|
<div class="stat-bar-bg"><div class="stat-bar-fill" data-ctr="{{ c.name }}" data-stat="mem_bar" style="width:0%"></div></div>
|
||||||
<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>
|
<span class="stat-pct" data-ctr="{{ c.name }}" data-stat="mem_pct">—</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="net" style="color:var(--cyan)">—</span></td>
|
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="net" style="color:var(--cyan)">—</span></td>
|
||||||
<td><span class="stat-pct" data-ctr="{{ c.name }}" data-stat="block" style="color:var(--yellow)">—</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="ct-image">{{ c.image }}</td>
|
<td class="col-extra app-extra ct-image" style="display:none;">{{ c.image }}</td>
|
||||||
<td class="ct-ports">{{ c.ports or '—' }}</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>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="8"><div class="empty-state"><i class="fas fa-inbox"></i>No containers found</div></td></tr>
|
<tr><td colspan="9"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -115,14 +115,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ═══════════════════════════════════════════════════════
|
{# ═══════════════════════════════════════════════════════
|
||||||
ALL CONTAINERS PAGE (root + all users)
|
ALL CONTAINERS PAGE
|
||||||
═══════════════════════════════════════════════════════ #}
|
═══════════════════════════════════════════════════════ #}
|
||||||
<div id="containers-page" class="page">
|
<div id="containers-page" class="page">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-layer-group"></i> All Containers</div>
|
<div class="card-title"><i class="fas fa-layer-group"></i> All Containers</div>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<span class="card-meta" id="all-ctr-meta">Loading…</span>
|
<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>
|
<button class="btn btn-ghost btn-sm" onclick="loadAllContainers()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,12 +139,14 @@
|
|||||||
<th>CPU</th>
|
<th>CPU</th>
|
||||||
<th>MEMORY</th>
|
<th>MEMORY</th>
|
||||||
<th>NET I/O</th>
|
<th>NET I/O</th>
|
||||||
<th>IMAGE</th>
|
<th class="col-extra all-extra" style="display:none;">DISK I/O</th>
|
||||||
<th>PORTS</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="all-containers-body">
|
<tbody id="all-containers-body">
|
||||||
<tr><td colspan="8"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>
|
<tr><td colspan="10"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,8 +163,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="restore-form">
|
<div class="restore-form">
|
||||||
|
|
||||||
<!-- Step 1: Source -->
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
|
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
@@ -184,29 +187,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top:14px; max-width:500px;">
|
<div class="form-group" style="margin-top:14px; max-width:500px;">
|
||||||
<label class="form-label">BACKUP FILE</label>
|
<label class="form-label">BACKUP FILE</label>
|
||||||
<select id="backup-file-select" class="form-input">
|
<select id="backup-file-select" class="form-input">
|
||||||
<optgroup label="Main Server" id="local-options">
|
<optgroup label="Main Server" id="local-options">
|
||||||
{% for b in backups %}
|
{% for b in backups %}<option value="{{ b }}" data-source="local">{{ b }}</option>{% else %}<option disabled>No local backups</option>{% endfor %}
|
||||||
<option value="{{ b }}" data-source="local">{{ b }}</option>
|
|
||||||
{% else %}
|
|
||||||
<option disabled>No local backups</option>
|
|
||||||
{% endfor %}
|
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="VM Backups" id="vm-options" style="display:none;">
|
<optgroup label="VM Backups" id="vm-options" style="display:none;">
|
||||||
{% for b in vm_backups %}
|
{% for b in vm_backups %}<option value="{{ b }}" data-source="vm">{{ b }}</option>{% else %}<option disabled>No VM backups</option>{% endfor %}
|
||||||
<option value="{{ b }}" data-source="vm">{{ b }}</option>
|
|
||||||
{% else %}
|
|
||||||
<option disabled>No VM backups</option>
|
|
||||||
{% endfor %}
|
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Target -->
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="form-section-title">STEP 2 — SELECT RESTORE TARGET</div>
|
<div class="form-section-title">STEP 2 — SELECT RESTORE TARGET</div>
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
@@ -215,8 +208,8 @@
|
|||||||
<div class="radio-body">
|
<div class="radio-body">
|
||||||
<span class="radio-icon">🎯</span>
|
<span class="radio-icon">🎯</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="radio-label">This Server</div>
|
<div class="radio-label">Restore on This Server</div>
|
||||||
<div class="radio-desc">{{ main_server }}</div>
|
<div class="radio-desc" id="this-server-desc">Loading hostname…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -225,8 +218,8 @@
|
|||||||
<div class="radio-body">
|
<div class="radio-body">
|
||||||
<span class="radio-icon">📡</span>
|
<span class="radio-icon">📡</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="radio-label">Remote Machine</div>
|
<div class="radio-label">External Machine</div>
|
||||||
<div class="radio-desc">via SSH</div>
|
<div class="radio-desc">via SSH — any IP</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -236,18 +229,17 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">TARGET IP</label>
|
<label class="form-label">TARGET IP</label>
|
||||||
<input type="text" id="remote-ip" class="form-input" placeholder="192.168.1.x or localhost">
|
<input type="text" id="remote-ip" class="form-input" placeholder="192.168.x.x or IP">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="max-width:100px;">
|
<div class="form-group" style="max-width:100px;">
|
||||||
<label class="form-label">SSH PORT</label>
|
<label class="form-label">SSH PORT</label>
|
||||||
<input type="text" id="remote-port" class="form-input" value="22" placeholder="22">
|
<input type="text" id="remote-port" class="form-input" value="22">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="max-width:120px;">
|
<div class="form-group" style="max-width:120px;">
|
||||||
<label class="form-label">SSH USER</label>
|
<label class="form-label">SSH USER</label>
|
||||||
<input type="text" id="remote-user" class="form-input" value="root">
|
<input type="text" id="remote-user" class="form-input" value="root">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top:8px;">
|
<div class="form-group" style="margin-top:8px;">
|
||||||
<label class="form-label">AUTHENTICATION</label>
|
<label class="form-label">AUTHENTICATION</label>
|
||||||
<div class="radio-group" style="gap:8px;">
|
<div class="radio-group" style="gap:8px;">
|
||||||
@@ -261,30 +253,25 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="key-field" class="form-group" style="margin-top:8px;">
|
<div id="key-field" class="form-group" style="margin-top:8px;">
|
||||||
<label class="form-label">SSH KEY PATH</label>
|
<label class="form-label">SSH KEY PATH</label>
|
||||||
<input type="text" id="ssh-key-path" class="form-input" value="/root/.ssh/contabo-key">
|
<input type="text" id="ssh-key-path" class="form-input" value="/root/.ssh/contabo-key">
|
||||||
</div>
|
</div>
|
||||||
<div id="password-field" class="form-group" style="display:none; margin-top:8px;">
|
<div id="password-field" class="form-group" style="display:none; margin-top:8px;">
|
||||||
<label class="form-label">SSH PASSWORD</label>
|
<label class="form-label">SSH PASSWORD</label>
|
||||||
<input type="password" id="ssh-password" class="form-input" placeholder="SSH password">
|
<input type="password" id="ssh-password" class="form-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Launch -->
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<button class="btn btn-danger btn-lg" onclick="launchRestore()" id="restore-btn">
|
<button class="btn btn-danger btn-lg" onclick="launchRestore()" id="restore-btn">
|
||||||
<i class="fas fa-play"></i> Start Restore
|
<i class="fas fa-play"></i> Start Restore
|
||||||
</button>
|
</button>
|
||||||
<p style="color:var(--text3); font-size:12px; margin-top:8px;">
|
<p style="color:var(--text3); font-size:12px; margin-top:8px;">⚠ Healthy running containers are skipped.</p>
|
||||||
⚠ Healthy running containers will be skipped automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Log -->
|
|
||||||
<div id="restore-log-wrapper" style="display:none; margin-top:20px;">
|
<div id="restore-log-wrapper" style="display:none; margin-top:20px;">
|
||||||
<div class="card-header" style="margin-bottom:10px;">
|
<div class="card-header" style="margin-bottom:10px;">
|
||||||
<div class="card-title"><i class="fas fa-terminal"></i> Restore Log</div>
|
<div class="card-title"><i class="fas fa-terminal"></i> Restore Log</div>
|
||||||
@@ -313,10 +300,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="backup-list" id="local-backup-list">
|
<div class="backup-list" id="local-backup-list">
|
||||||
{% for b in backups %}
|
{% for b in backups %}
|
||||||
<div class="backup-item">
|
<div class="backup-item"><span class="backup-name">{{ b }}</span><button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button></div>
|
||||||
<span class="backup-name">{{ b }}</span>
|
|
||||||
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>
|
<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -329,10 +313,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="backup-list" id="vm-backup-list">
|
<div class="backup-list" id="vm-backup-list">
|
||||||
{% for b in vm_backups %}
|
{% for b in vm_backups %}
|
||||||
<div class="backup-item">
|
<div class="backup-item"><span class="backup-name">{{ b }}</span><button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button></div>
|
||||||
<span class="backup-name">{{ b }}</span>
|
|
||||||
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>
|
<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -346,8 +327,6 @@
|
|||||||
USERS PAGE
|
USERS PAGE
|
||||||
═══════════════════════════════════════════════════════ #}
|
═══════════════════════════════════════════════════════ #}
|
||||||
<div id="users-page" class="page">
|
<div id="users-page" class="page">
|
||||||
|
|
||||||
<!-- Create User -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-user-plus"></i> Create New User</div>
|
<div class="card-title"><i class="fas fa-user-plus"></i> Create New User</div>
|
||||||
@@ -359,10 +338,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">PASSWORD (optional)</label>
|
<label class="form-label">PASSWORD (optional)</label>
|
||||||
<input type="password" id="new-password" class="form-input" placeholder="Leave blank for no password">
|
<input type="password" id="new-password" class="form-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">DISK QUOTA (MB, optional)</label>
|
<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">
|
<input type="number" id="new-quota" class="form-input" placeholder="e.g. 10240 for 10GB">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="justify-content:flex-end;">
|
<div class="form-group" style="justify-content:flex-end;">
|
||||||
@@ -370,16 +349,13 @@
|
|||||||
<input type="checkbox" id="new-docker" checked>
|
<input type="checkbox" id="new-docker" checked>
|
||||||
<span>Setup rootless Docker</span>
|
<span>Setup rootless Docker</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary" onclick="createUser()">
|
<button class="btn btn-primary" onclick="createUser()"><i class="fas fa-plus"></i> Create User</button>
|
||||||
<i class="fas fa-plus"></i> Create User
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="create-user-result" class="alert" style="margin-top:14px;"></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 id="create-user-log" class="log-console" style="display:none; margin-top:12px; max-height:200px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users List -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-users"></i> System Users</div>
|
<div class="card-title"><i class="fas fa-users"></i> System Users</div>
|
||||||
@@ -389,52 +365,40 @@
|
|||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<div class="user-card" onclick="loadUserContainers('{{ u.name }}')">
|
<div class="user-card" onclick="loadUserContainers('{{ u.name }}')">
|
||||||
<div class="user-card-top">
|
<div class="user-card-top">
|
||||||
<div class="user-avatar" style="background: #667eea">
|
<div class="user-avatar" style="background:{{ loop.index | colorize if colorize is defined else '#3b82f6' }}">{{ u.name[0].upper() }}</div>
|
||||||
{{ u.name[0].upper() }}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="user-name">{{ u.name }}</div>
|
<div class="user-name">{{ u.name }}</div>
|
||||||
<div class="user-uid">uid {{ u.uid }} · {{ u.home }}</div>
|
<div class="user-uid">uid {{ u.uid }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-tags">
|
<div class="user-tags">
|
||||||
{% if u.has_docker %}<span class="user-tag docker"><i class="fab fa-docker"></i> rootless docker</span>{% endif %}
|
{% 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 on</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>
|
||||||
<div class="user-stats">
|
<div class="user-stats">
|
||||||
<div class="user-stat">Disk: <strong>{{ u.disk_used }}</strong></div>
|
<div class="user-stat">Disk: <strong>{{ u.disk_used }}</strong></div>
|
||||||
<div class="user-stat">Containers: <strong>{{ u.container_count }}</strong></div>
|
<div class="user-stat">Ctrs: <strong>{{ u.container_count }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state" style="grid-column:1/-1;">
|
<div class="empty-state" style="grid-column:1/-1"><i class="fas fa-user-slash"></i>No non-system users</div>
|
||||||
<i class="fas fa-user-slash"></i>No non-system users found
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Detail Panel -->
|
|
||||||
<div class="card" id="user-detail-panel" style="display:none;">
|
<div class="card" id="user-detail-panel" style="display:none;">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title"><i class="fas fa-cube"></i> <span id="user-detail-title">Containers</span></div>
|
<div class="card-title"><i class="fas fa-cube"></i> <span id="user-detail-title">Containers</span></div>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;">
|
||||||
<button class="btn btn-ghost btn-sm" id="user-delete-btn" onclick="deleteUser()">
|
<button class="btn btn-ghost btn-sm" style="color:var(--red);" onclick="deleteUser()"><i class="fas fa-trash"></i> Delete</button>
|
||||||
<i class="fas fa-trash"></i> Delete User
|
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('user-detail-panel').style.display='none'">✕</button>
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('user-detail-panel').style.display='none'">
|
|
||||||
✕ Close
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="ct-table">
|
<table class="ct-table">
|
||||||
<thead>
|
<thead><tr><th>NAME</th><th>STATUS</th><th>IMAGE</th><th>PORTS</th></tr></thead>
|
||||||
<tr><th>NAME</th><th>STATUS</th><th>IMAGE</th><th>PORTS</th></tr>
|
<tbody id="user-containers-body"></tbody>
|
||||||
</thead>
|
|
||||||
<tbody id="user-containers-body">
|
|
||||||
<tr><td colspan="4"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i></div></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div id="user-action-result" class="alert" style="margin-top:14px;"></div>
|
<div id="user-action-result" class="alert" style="margin-top:14px;"></div>
|
||||||
@@ -446,27 +410,14 @@
|
|||||||
═══════════════════════════════════════════════════════ #}
|
═══════════════════════════════════════════════════════ #}
|
||||||
<div id="settings-page" class="page">
|
<div id="settings-page" class="page">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header"><div class="card-title"><i class="fas fa-sliders"></i> Platform Settings</div></div>
|
||||||
<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 style="max-width:460px; display:flex; flex-direction:column; gap:14px;">
|
||||||
<div class="form-group">
|
<div class="form-group"><label class="form-label">MAIN SERVER IP</label><input class="form-input" value="{{ main_server }}" readonly></div>
|
||||||
<label class="form-label">MAIN SERVER IP</label>
|
<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>
|
||||||
<input class="form-input" value="{{ main_server }}" readonly>
|
<div class="form-group"><label class="form-label">VM BACKUP PATH</label><input class="form-input" value="/backups/main-server/" readonly></div>
|
||||||
</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">
|
<div class="form-group"><label class="form-label">DOCKER VERSION</label><input class="form-input" value="{{ system.docker_v }}" readonly></div>
|
||||||
<label class="form-label">VM BACKUP PATH</label>
|
<button class="btn btn-ghost" onclick="refreshAll()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||||||
<input class="form-input" value="/backups/main-server/" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">PLATFORM 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 All Data</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,16 +426,25 @@
|
|||||||
JAVASCRIPT
|
JAVASCRIPT
|
||||||
═══════════════════════════════════════════════════════ #}
|
═══════════════════════════════════════════════════════ #}
|
||||||
<script>
|
<script>
|
||||||
// ── Page routing ────────────────────────────────────────
|
// ── Fetch and display current hostname in restore target label ───
|
||||||
const PAGES = {
|
(async function() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/system');
|
||||||
|
const d = await r.json();
|
||||||
|
const el = document.getElementById('this-server-desc');
|
||||||
|
if (el && d.hostname) el.textContent = d.hostname;
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Pages ───────────────────────────────────────────────
|
||||||
|
const PAGE_INFO = {
|
||||||
'dashboard': { title:'Dashboard', sub:'{{ main_server }}' },
|
'dashboard': { title:'Dashboard', sub:'{{ main_server }}' },
|
||||||
'containers': { title: 'All Containers', sub: 'root + all users' },
|
'containers': { title:'All Containers', sub:'main server · all users' },
|
||||||
'restore': { title:'Restore', sub:'backup → target' },
|
'restore': { title:'Restore', sub:'backup → target' },
|
||||||
'backups': { title:'Backups', sub:'local & VM' },
|
'backups': { title:'Backups', sub:'local & VM' },
|
||||||
'users': { title:'User Management', sub:'linux + docker' },
|
'users': { title:'User Management', sub:'linux + docker' },
|
||||||
'settings': { title:'Settings', sub:'platform config' },
|
'settings': { title:'Settings', sub:'platform config' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function showPage(name) {
|
function showPage(name) {
|
||||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
@@ -492,21 +452,58 @@ function showPage(name) {
|
|||||||
if (page) page.classList.add('active');
|
if (page) page.classList.add('active');
|
||||||
const nav = document.querySelector(`[data-page="${name}"]`);
|
const nav = document.querySelector(`[data-page="${name}"]`);
|
||||||
if (nav) nav.classList.add('active');
|
if (nav) nav.classList.add('active');
|
||||||
const info = PAGES[name] || {};
|
const info = PAGE_INFO[name] || {};
|
||||||
document.getElementById('page-title').textContent = info.title || name;
|
document.getElementById('page-title').textContent = info.title || name;
|
||||||
document.getElementById('page-subtitle').textContent = info.sub || '';
|
document.getElementById('page-subtitle').textContent = info.sub || '';
|
||||||
// Lazy load
|
|
||||||
if (name === 'containers') loadAllContainers();
|
if (name === 'containers') loadAllContainers();
|
||||||
if (name === 'backups') refreshBackupsList();
|
if (name === 'backups') refreshBackupsList();
|
||||||
if (name === 'users') loadUsers();
|
if (name === 'users') loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.nav-item').forEach(item => {
|
document.querySelectorAll('.nav-item').forEach(item => {
|
||||||
item.addEventListener('click', e => {
|
item.addEventListener('click', e => { e.preventDefault(); showPage(item.dataset.page); });
|
||||||
e.preventDefault();
|
|
||||||
showPage(item.dataset.page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Collapsible columns ─────────────────────────────────
|
||||||
|
const extraState = { app: false, all: false };
|
||||||
|
function toggleExtraColumns(prefix) {
|
||||||
|
extraState[prefix] = !extraState[prefix];
|
||||||
|
const show = extraState[prefix];
|
||||||
|
document.querySelectorAll(`.${prefix}-extra`).forEach(el => {
|
||||||
|
el.style.display = show ? '' : 'none';
|
||||||
});
|
});
|
||||||
|
const btn = document.getElementById(`${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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Container action (start/stop/restart) ───────────────
|
||||||
|
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();
|
||||||
|
btn.innerHTML = d.success ? '<i class="fas fa-check"></i>' : '<i class="fas fa-times"></i>';
|
||||||
|
btn.style.opacity = '1';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = origHTML;
|
||||||
|
btn.disabled = false;
|
||||||
|
setTimeout(() => { refreshContainerStats(); loadAllContainers(); }, 2000);
|
||||||
|
}, 1500);
|
||||||
|
} catch (e) {
|
||||||
|
btn.innerHTML = origHTML;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Server status ────────────────────────────────────────
|
// ── Server status ────────────────────────────────────────
|
||||||
async function checkServerStatus() {
|
async function checkServerStatus() {
|
||||||
@@ -516,11 +513,9 @@ async function checkServerStatus() {
|
|||||||
const dot = document.getElementById('pulse-dot');
|
const dot = document.getElementById('pulse-dot');
|
||||||
const text = document.getElementById('server-status-text');
|
const text = document.getElementById('server-status-text');
|
||||||
if (d.status === 'online') {
|
if (d.status === 'online') {
|
||||||
dot.className = 'pulse-dot online';
|
dot.className = 'pulse-dot online'; text.textContent = 'Online';
|
||||||
text.textContent = 'Online';
|
|
||||||
} else {
|
} else {
|
||||||
dot.className = 'pulse-dot offline';
|
dot.className = 'pulse-dot offline'; text.textContent = 'Offline';
|
||||||
text.textContent = 'Offline';
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -538,8 +533,13 @@ async function refreshSystemMetrics() {
|
|||||||
document.getElementById('g-mem').style.width = Math.min(parseFloat(d.mem_pct)||0, 100)+'%';
|
document.getElementById('g-mem').style.width = Math.min(parseFloat(d.mem_pct)||0, 100)+'%';
|
||||||
document.getElementById('g-disk').style.width = Math.min(parseFloat(d.disk_pct)||0, 100)+'%';
|
document.getElementById('g-disk').style.width = Math.min(parseFloat(d.disk_pct)||0, 100)+'%';
|
||||||
document.getElementById('uptime-chip').textContent = d.uptime;
|
document.getElementById('uptime-chip').textContent = d.uptime;
|
||||||
const settingsUptime = document.getElementById('settings-uptime');
|
const su = document.getElementById('settings-uptime');
|
||||||
if (settingsUptime) settingsUptime.value = d.uptime;
|
if (su) su.value = d.uptime;
|
||||||
|
// Update "this server" label in restore page with live hostname
|
||||||
|
if (d.hostname) {
|
||||||
|
const el = document.getElementById('this-server-desc');
|
||||||
|
if (el) el.textContent = d.hostname;
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,13 +548,11 @@ async function refreshContainerStats() {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch('/api/stats');
|
const r = await fetch('/api/stats');
|
||||||
const stats = await r.json();
|
const stats = await r.json();
|
||||||
|
|
||||||
document.querySelectorAll('[data-stat]').forEach(el => {
|
document.querySelectorAll('[data-stat]').forEach(el => {
|
||||||
const name = el.dataset.ctr;
|
const name = el.dataset.ctr;
|
||||||
const stat = el.dataset.stat;
|
const stat = el.dataset.stat;
|
||||||
const s = stats[name];
|
const s = stats[name];
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
|
||||||
if (stat === 'cpu') el.textContent = s.cpu || '—';
|
if (stat === 'cpu') el.textContent = s.cpu || '—';
|
||||||
if (stat === 'net') el.textContent = s.net || '—';
|
if (stat === 'net') el.textContent = s.net || '—';
|
||||||
if (stat === 'block') el.textContent = s.block || '—';
|
if (stat === 'block') el.textContent = s.block || '—';
|
||||||
@@ -568,16 +566,24 @@ async function refreshContainerStats() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── All containers (root + users) ───────────────────────
|
// ── All containers ───────────────────────────────────────
|
||||||
|
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() {
|
async function loadAllContainers() {
|
||||||
const body = document.getElementById('all-containers-body');
|
const body = document.getElementById('all-containers-body');
|
||||||
const meta = document.getElementById('all-ctr-meta');
|
const meta = document.getElementById('all-ctr-meta');
|
||||||
body.innerHTML = '<tr><td colspan="8"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>';
|
body.innerHTML = '<tr><td colspan="10"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [ctrRes, statRes] = await Promise.all([
|
const [ctrRes, statRes] = await Promise.all([
|
||||||
fetch('/api/containers/all'),
|
fetch('/api/containers/all'),
|
||||||
fetch('/api/stats')
|
fetch('/api/stats'),
|
||||||
]);
|
]);
|
||||||
const { containers, running } = await ctrRes.json();
|
const { containers, running } = await ctrRes.json();
|
||||||
const stats = await statRes.json();
|
const stats = await statRes.json();
|
||||||
@@ -586,32 +592,34 @@ async function loadAllContainers() {
|
|||||||
document.getElementById('nav-badge-containers').textContent = containers.length;
|
document.getElementById('nav-badge-containers').textContent = containers.length;
|
||||||
|
|
||||||
if (!containers.length) {
|
if (!containers.length) {
|
||||||
body.innerHTML = '<tr><td colspan="8"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
|
body.innerHTML = '<tr><td colspan="10"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const showExtra = extraState.all;
|
||||||
body.innerHTML = containers.map(c => {
|
body.innerHTML = containers.map(c => {
|
||||||
const up = c.status.includes('Up');
|
const up = c.status.includes('Up');
|
||||||
const s = stats[c.name] || {};
|
const s = stats[c.name] || {};
|
||||||
const pct = parseFloat(s.mem_pct) || 0;
|
const pct = parseFloat(s.mem_pct) || 0;
|
||||||
const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : '';
|
const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : '';
|
||||||
|
const ed = showExtra ? '' : 'display:none;';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="ct-name">${c.name}</td>
|
<td class="ct-name">${c.name}</td>
|
||||||
<td class="ct-owner">${c.owner}</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>${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><span class="stat-pct">${s.cpu||'—'}</span></td>
|
||||||
<td>
|
<td><div class="stat-bar-wrap">
|
||||||
<div class="stat-bar-wrap">
|
|
||||||
<div class="stat-bar-bg"><div class="stat-bar-fill ${cls}" style="width:${Math.min(pct,100)}%"></div></div>
|
<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>
|
<span class="stat-pct">${s.mem_pct||'—'}</span>
|
||||||
</div>
|
</div></td>
|
||||||
</td>
|
|
||||||
<td><span class="stat-pct" style="color:var(--cyan)">${s.net||'—'}</span></td>
|
<td><span class="stat-pct" style="color:var(--cyan)">${s.net||'—'}</span></td>
|
||||||
<td class="ct-image">${c.image}</td>
|
<td class="col-extra all-extra" style="${ed}"><span class="stat-pct" style="color:var(--yellow)">${s.block||'—'}</span></td>
|
||||||
<td class="ct-ports">${c.ports||'—'}</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>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.innerHTML = `<tr><td colspan="8"><div class="empty-state"><i class="fas fa-exclamation-triangle"></i>${e}</div></td></tr>`;
|
body.innerHTML = `<tr><td colspan="10"><div class="empty-state">${e}</div></td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,8 +632,6 @@ async function refreshBackupsList() {
|
|||||||
renderBackupList(d.vm, 'vm-backup-list', 'vm');
|
renderBackupList(d.vm, 'vm-backup-list', 'vm');
|
||||||
document.getElementById('stat-local-bk').textContent = d.local.length;
|
document.getElementById('stat-local-bk').textContent = d.local.length;
|
||||||
document.getElementById('stat-vm-bk').textContent = d.vm.length;
|
document.getElementById('stat-vm-bk').textContent = d.vm.length;
|
||||||
|
|
||||||
// Refresh selects on restore page
|
|
||||||
const lo = document.getElementById('local-options');
|
const lo = document.getElementById('local-options');
|
||||||
const vo = document.getElementById('vm-options');
|
const vo = document.getElementById('vm-options');
|
||||||
lo.innerHTML = d.local.length
|
lo.innerHTML = d.local.length
|
||||||
@@ -636,14 +642,10 @@ async function refreshBackupsList() {
|
|||||||
: '<option disabled>No VM backups</option>';
|
: '<option disabled>No VM backups</option>';
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBackupList(items, id, source) {
|
function renderBackupList(items, id, source) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!items || !items.length) {
|
if (!items || !items.length) { el.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>'; return; }
|
||||||
el.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = items.map(b => `
|
el.innerHTML = items.map(b => `
|
||||||
<div class="backup-item">
|
<div class="backup-item">
|
||||||
<span class="backup-name">${b}</span>
|
<span class="backup-name">${b}</span>
|
||||||
@@ -653,6 +655,7 @@ function renderBackupList(items, id, source) {
|
|||||||
|
|
||||||
// ── Users ────────────────────────────────────────────────
|
// ── Users ────────────────────────────────────────────────
|
||||||
let selectedUser = null;
|
let selectedUser = null;
|
||||||
|
const userColors = ['#3b82f6','#a78bfa','#22c55e','#f59e0b','#ef4444','#22d3ee'];
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
@@ -660,29 +663,22 @@ async function loadUsers() {
|
|||||||
const users = await r.json();
|
const users = await r.json();
|
||||||
document.getElementById('nav-badge-users').textContent = users.length;
|
document.getElementById('nav-badge-users').textContent = users.length;
|
||||||
document.getElementById('stat-users').textContent = users.length;
|
document.getElementById('stat-users').textContent = users.length;
|
||||||
|
|
||||||
const colors = ['#3b82f6','#a78bfa','#22c55e','#f59e0b','#ef4444','#22d3ee'];
|
|
||||||
const grid = document.getElementById('users-grid');
|
const grid = document.getElementById('users-grid');
|
||||||
if (!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 = '<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) => `
|
grid.innerHTML = users.map((u, i) => `
|
||||||
<div class="user-card" onclick="loadUserContainers('${u.name}')">
|
<div class="user-card" onclick="loadUserContainers('${u.name}')">
|
||||||
<div class="user-card-top">
|
<div class="user-card-top">
|
||||||
<div class="user-avatar" style="background:${colors[i % colors.length]}">${u.name[0].toUpperCase()}</div>
|
<div class="user-avatar" style="background:${userColors[i % userColors.length]}">${u.name[0].toUpperCase()}</div>
|
||||||
<div>
|
<div><div class="user-name">${u.name}</div><div class="user-uid">uid ${u.uid}</div></div>
|
||||||
<div class="user-name">${u.name}</div>
|
|
||||||
<div class="user-uid">uid ${u.uid} · ${u.home}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="user-tags">
|
<div class="user-tags">
|
||||||
${u.has_docker ? '<span class="user-tag docker"><i class="fab fa-docker"></i> rootless docker</span>' : ''}
|
${u.has_docker ? '<span class="user-tag docker"><i class="fab fa-docker"></i> docker</span>' : ''}
|
||||||
${u.linger ? '<span class="user-tag linger">linger on</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>
|
||||||
<div class="user-stats">
|
<div class="user-stats">
|
||||||
<div class="user-stat">Disk: <strong>${u.disk_used}</strong></div>
|
<div class="user-stat">Disk: <strong>${u.disk_used}</strong></div>
|
||||||
<div class="user-stat">Containers: <strong>${u.container_count}</strong></div>
|
<div class="user-stat">Ctrs: <strong>${u.container_count}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -693,19 +689,13 @@ async function loadUserContainers(username) {
|
|||||||
const panel = document.getElementById('user-detail-panel');
|
const panel = document.getElementById('user-detail-panel');
|
||||||
panel.style.display = '';
|
panel.style.display = '';
|
||||||
document.getElementById('user-detail-title').textContent = `${username}'s Containers`;
|
document.getElementById('user-detail-title').textContent = `${username}'s Containers`;
|
||||||
document.getElementById('user-delete-btn').dataset.username = username;
|
|
||||||
document.getElementById('user-action-result').className = 'alert';
|
document.getElementById('user-action-result').className = 'alert';
|
||||||
|
|
||||||
const body = document.getElementById('user-containers-body');
|
const body = document.getElementById('user-containers-body');
|
||||||
body.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i></div></td></tr>';
|
body.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i></div></td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/users/${username}/containers`);
|
const r = await fetch(`/api/users/${username}/containers`);
|
||||||
const ctrs = await r.json();
|
const ctrs = await r.json();
|
||||||
if (!ctrs.length) {
|
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 = '<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 => {
|
body.innerHTML = ctrs.map(c => {
|
||||||
const up = c.status.includes('Up');
|
const up = c.status.includes('Up');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
@@ -716,9 +706,7 @@ async function loadUserContainers(username) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
panel.scrollIntoView({ behavior: 'smooth' });
|
panel.scrollIntoView({ behavior: 'smooth' });
|
||||||
} catch (e) {
|
} catch {}
|
||||||
body.innerHTML = `<tr><td colspan="4"><div class="empty-state">${e}</div></td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser() {
|
async function createUser() {
|
||||||
@@ -726,32 +714,21 @@ async function createUser() {
|
|||||||
const password = document.getElementById('new-password').value.trim();
|
const password = document.getElementById('new-password').value.trim();
|
||||||
const quota = document.getElementById('new-quota').value.trim();
|
const quota = document.getElementById('new-quota').value.trim();
|
||||||
const docker = document.getElementById('new-docker').checked;
|
const docker = document.getElementById('new-docker').checked;
|
||||||
|
if (!username) { showAlert('create-user-result', 'error', 'Username required'); return; }
|
||||||
if (!username) { showAlert('create-user-result', 'error', 'Username is required'); return; }
|
|
||||||
|
|
||||||
const btn = event.target;
|
const btn = event.target;
|
||||||
btn.disabled = true;
|
btn.disabled = true; btn.textContent = 'Creating…';
|
||||||
btn.textContent = 'Creating…';
|
|
||||||
|
|
||||||
const logEl = document.getElementById('create-user-log');
|
const logEl = document.getElementById('create-user-log');
|
||||||
logEl.style.display = '';
|
logEl.style.display = ''; logEl.textContent = '⏳ Setting up…\n';
|
||||||
logEl.textContent = '⏳ Setting up user…\n';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/users/create', {
|
const r = await fetch('/api/users/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ username, password: password||null, setup_docker: docker, disk_quota_mb: quota ? parseInt(quota) : null })
|
||||||
username,
|
|
||||||
password: password || null,
|
|
||||||
setup_docker: docker,
|
|
||||||
disk_quota_mb: quota ? parseInt(quota) : null,
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
logEl.textContent = d.message || '';
|
logEl.textContent = d.message || '';
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
showAlert('create-user-result', 'success', `✅ User "${username}" created successfully`);
|
showAlert('create-user-result', 'success', `✅ User "${username}" created`);
|
||||||
document.getElementById('new-username').value = '';
|
document.getElementById('new-username').value = '';
|
||||||
document.getElementById('new-password').value = '';
|
document.getElementById('new-password').value = '';
|
||||||
document.getElementById('new-quota').value = '';
|
document.getElementById('new-quota').value = '';
|
||||||
@@ -759,35 +736,24 @@ async function createUser() {
|
|||||||
} else {
|
} else {
|
||||||
showAlert('create-user-result', 'error', `❌ ${d.message}`);
|
showAlert('create-user-result', 'error', `❌ ${d.message}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) { showAlert('create-user-result', 'error', `${e}`); }
|
||||||
showAlert('create-user-result', 'error', `Request failed: ${e}`);
|
finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plus"></i> Create User'; }
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="fas fa-plus"></i> Create User';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser() {
|
async function deleteUser() {
|
||||||
const username = selectedUser;
|
if (!selectedUser) return;
|
||||||
if (!username) return;
|
if (!confirm(`Delete user "${selectedUser}"?`)) return;
|
||||||
if (!confirm(`Delete user "${username}"? This cannot be undone.\nRemove home directory too?`)) return;
|
const removeHome = confirm(`Also delete /home/${selectedUser}?`);
|
||||||
const removeHome = confirm(`Also delete /home/${username}?`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/users/delete', {
|
const r = await fetch('/api/users/delete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, remove_home: removeHome })
|
body: JSON.stringify({ username: selectedUser, remove_home: removeHome })
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
showAlert('user-action-result', d.success ? 'success' : 'error', d.message);
|
showAlert('user-action-result', d.success ? 'success' : 'error', d.message);
|
||||||
if (d.success) {
|
if (d.success) { document.getElementById('user-detail-panel').style.display='none'; loadUsers(); }
|
||||||
document.getElementById('user-detail-panel').style.display = 'none';
|
} catch (e) { showAlert('user-action-result', 'error', `${e}`); }
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('user-action-result', 'error', `Request failed: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAlert(id, type, msg) {
|
function showAlert(id, type, msg) {
|
||||||
@@ -806,18 +772,15 @@ function updateBackupList() {
|
|||||||
if (!opt.disabled && opt.parentElement.style.display !== 'none') { opt.selected = true; break; }
|
if (!opt.disabled && opt.parentElement.style.display !== 'none') { opt.selected = true; break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRemoteFields() {
|
function toggleRemoteFields() {
|
||||||
const t = document.querySelector('input[name="restore_target"]:checked').value;
|
document.getElementById('remote-fields').style.display =
|
||||||
document.getElementById('remote-fields').style.display = t === 'remote' ? '' : 'none';
|
document.querySelector('input[name="restore_target"]:checked').value === 'remote' ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAuthFields() {
|
function toggleAuthFields() {
|
||||||
const m = document.querySelector('input[name="auth_method"]:checked').value;
|
const m = document.querySelector('input[name="auth_method"]:checked').value;
|
||||||
document.getElementById('key-field').style.display = m==='key' ? '' : 'none';
|
document.getElementById('key-field').style.display = m==='key' ? '' : 'none';
|
||||||
document.getElementById('password-field').style.display = m==='password' ? '' : 'none';
|
document.getElementById('password-field').style.display = m==='password' ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickRestore(source, filename) {
|
function quickRestore(source, filename) {
|
||||||
showPage('restore');
|
showPage('restore');
|
||||||
document.querySelector(`input[name="backup_source"][value="${source}"]`).checked = true;
|
document.querySelector(`input[name="backup_source"][value="${source}"]`).checked = true;
|
||||||
@@ -829,18 +792,15 @@ function quickRestore(source, filename) {
|
|||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentJobId = null;
|
let currentJobId = null, pollInterval = null;
|
||||||
let pollInterval = null;
|
|
||||||
|
|
||||||
async function launchRestore() {
|
async function launchRestore() {
|
||||||
const src = document.querySelector('input[name="backup_source"]:checked').value;
|
const src = document.querySelector('input[name="backup_source"]:checked').value;
|
||||||
const file = document.getElementById('backup-file-select').value;
|
const file = document.getElementById('backup-file-select').value;
|
||||||
const target = document.querySelector('input[name="restore_target"]:checked').value;
|
const target = document.querySelector('input[name="restore_target"]:checked').value;
|
||||||
|
if (!file || file === 'undefined') { alert('Select a backup file.'); return; }
|
||||||
if (!file || file === 'undefined') { alert('Select a backup file first.'); return; }
|
|
||||||
|
|
||||||
const payload = { backup_source: src, backup_file: file, target };
|
const payload = { backup_source: src, backup_file: file, target };
|
||||||
|
|
||||||
if (target === 'remote') {
|
if (target === 'remote') {
|
||||||
payload.remote_ip = document.getElementById('remote-ip').value.trim();
|
payload.remote_ip = document.getElementById('remote-ip').value.trim();
|
||||||
payload.remote_port = document.getElementById('remote-port').value.trim() || '22';
|
payload.remote_port = document.getElementById('remote-port').value.trim() || '22';
|
||||||
@@ -850,40 +810,30 @@ async function launchRestore() {
|
|||||||
payload.ssh_key_path = document.getElementById('ssh-key-path').value.trim();
|
payload.ssh_key_path = document.getElementById('ssh-key-path').value.trim();
|
||||||
else
|
else
|
||||||
payload.ssh_password = document.getElementById('ssh-password').value;
|
payload.ssh_password = document.getElementById('ssh-password').value;
|
||||||
if (!payload.remote_ip) { alert('Enter a target IP.'); return; }
|
if (!payload.remote_ip) { alert('Enter target IP.'); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Restore "${file}" → ${target === 'local' ? 'this server' : payload.remote_ip + ':' + payload.remote_port}?`)) return;
|
const tgtLabel = target === 'local'
|
||||||
|
? (document.getElementById('this-server-desc').textContent || 'this server')
|
||||||
|
: `${payload.remote_ip}:${payload.remote_port||22}`;
|
||||||
|
if (!confirm(`Restore "${file}" → ${tgtLabel}?`)) return;
|
||||||
|
|
||||||
const btn = document.getElementById('restore-btn');
|
const btn = document.getElementById('restore-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Starting…';
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Starting…';
|
|
||||||
|
|
||||||
const wrapper = document.getElementById('restore-log-wrapper');
|
document.getElementById('restore-log-wrapper').style.display = '';
|
||||||
wrapper.style.display = '';
|
|
||||||
document.getElementById('restore-log').innerHTML = '';
|
document.getElementById('restore-log').innerHTML = '';
|
||||||
const badge = document.getElementById('restore-status-badge');
|
const badge = document.getElementById('restore-status-badge');
|
||||||
badge.style = 'background:rgba(59,130,246,0.15);color:var(--accent2)';
|
badge.style = 'background:rgba(59,130,246,0.15);color:var(--accent2)';
|
||||||
badge.textContent = 'Running…';
|
badge.textContent = 'Running…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/restore/start', {
|
const res = await fetch('/restore/start', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) });
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) { appendLog('❌ '+data.error, 'error'); btn.disabled=false; btn.innerHTML='<i class="fas fa-play"></i> Start Restore'; return; }
|
||||||
appendLog('❌ ' + data.error, 'error');
|
|
||||||
btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Start Restore';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentJobId = data.job_id;
|
currentJobId = data.job_id;
|
||||||
pollRestore();
|
pollRestore();
|
||||||
} catch (e) {
|
} catch (e) { appendLog('❌ '+e, 'error'); btn.disabled=false; btn.innerHTML='<i class="fas fa-play"></i> Start Restore'; }
|
||||||
appendLog('❌ ' + e, 'error');
|
|
||||||
btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Start Restore';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollRestore() {
|
function pollRestore() {
|
||||||
@@ -896,21 +846,14 @@ function pollRestore() {
|
|||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
d.log.slice(lastLine).forEach(l => appendLog(l));
|
d.log.slice(lastLine).forEach(l => appendLog(l));
|
||||||
lastLine = d.log.length;
|
lastLine = d.log.length;
|
||||||
document.getElementById('restore-elapsed').textContent = `⏱ ${d.elapsed}s elapsed`;
|
document.getElementById('restore-elapsed').textContent = `⏱ ${d.elapsed}s`;
|
||||||
|
|
||||||
if (d.status !== 'running') {
|
if (d.status !== 'running') {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
const badge = document.getElementById('restore-status-badge');
|
const badge = document.getElementById('restore-status-badge');
|
||||||
if (d.status === 'done') {
|
if (d.status === 'done') { badge.style='background:rgba(34,197,94,0.15);color:var(--green)'; badge.textContent='✅ Done'; }
|
||||||
badge.style = 'background:rgba(34,197,94,0.15);color:var(--green)';
|
else { badge.style='background:rgba(239,68,68,0.15);color:var(--red)'; badge.textContent='❌ Error'; }
|
||||||
badge.textContent = '✅ Done';
|
|
||||||
} else {
|
|
||||||
badge.style = 'background:rgba(239,68,68,0.15);color:var(--red)';
|
|
||||||
badge.textContent = '❌ Error';
|
|
||||||
}
|
|
||||||
const btn = document.getElementById('restore-btn');
|
const btn = document.getElementById('restore-btn');
|
||||||
btn.disabled = false;
|
btn.disabled=false; btn.innerHTML='<i class="fas fa-play"></i> Start Restore';
|
||||||
btn.innerHTML = '<i class="fas fa-play"></i> Start Restore';
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -932,14 +875,10 @@ function appendLog(line, type) {
|
|||||||
|
|
||||||
// ── Global refresh ───────────────────────────────────────
|
// ── Global refresh ───────────────────────────────────────
|
||||||
function refreshAll() {
|
function refreshAll() {
|
||||||
const icon = document.getElementById('refresh-icon');
|
const btn = document.querySelector('.icon-btn');
|
||||||
const btn = icon.closest('.icon-btn');
|
|
||||||
btn.classList.add('spinning');
|
btn.classList.add('spinning');
|
||||||
Promise.all([
|
Promise.all([checkServerStatus(), refreshSystemMetrics(), refreshContainerStats()])
|
||||||
checkServerStatus(),
|
.finally(() => btn.classList.remove('spinning'));
|
||||||
refreshSystemMetrics(),
|
|
||||||
refreshContainerStats(),
|
|
||||||
]).finally(() => btn.classList.remove('spinning'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Boot ─────────────────────────────────────────────────
|
// ── Boot ─────────────────────────────────────────────────
|
||||||
@@ -948,12 +887,40 @@ refreshSystemMetrics();
|
|||||||
refreshContainerStats();
|
refreshContainerStats();
|
||||||
document.getElementById('nav-badge-users').textContent = '{{ users|length }}';
|
document.getElementById('nav-badge-users').textContent = '{{ users|length }}';
|
||||||
document.getElementById('nav-badge-containers').textContent = '{{ containers|length }}';
|
document.getElementById('nav-badge-containers').textContent = '{{ containers|length }}';
|
||||||
|
setInterval(() => { refreshSystemMetrics(); refreshContainerStats(); }, 15000);
|
||||||
// Auto-refresh every 15s
|
|
||||||
setInterval(() => {
|
|
||||||
refreshSystemMetrics();
|
|
||||||
refreshContainerStats();
|
|
||||||
}, 15000);
|
|
||||||
setInterval(checkServerStatus, 30000);
|
setInterval(checkServerStatus, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Container action buttons */
|
||||||
|
.action-btns { display:flex; gap:5px; align-items:center; }
|
||||||
|
.ctr-action-btn {
|
||||||
|
width:28px; height:28px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
border-radius:7px; border:none; cursor:pointer;
|
||||||
|
font-size:11px;
|
||||||
|
transition:background 0.15s, transform 0.1s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.ctr-action-btn:hover { transform:scale(1.12); }
|
||||||
|
.ctr-action-btn:disabled { opacity:0.4; cursor:not-allowed; transform:none; }
|
||||||
|
|
||||||
|
.ctr-action-btn.restart {
|
||||||
|
background:rgba(59,130,246,0.15); color:var(--accent2);
|
||||||
|
}
|
||||||
|
.ctr-action-btn.restart:hover { background:rgba(59,130,246,0.3); }
|
||||||
|
|
||||||
|
.ctr-action-btn.stop {
|
||||||
|
background:rgba(239,68,68,0.12); color:var(--red);
|
||||||
|
}
|
||||||
|
.ctr-action-btn.stop:hover { background:rgba(239,68,68,0.25); }
|
||||||
|
|
||||||
|
.ctr-action-btn.start {
|
||||||
|
background:rgba(34,197,94,0.12); color:var(--green);
|
||||||
|
}
|
||||||
|
.ctr-action-btn.start:hover { background:rgba(34,197,94,0.25); }
|
||||||
|
|
||||||
|
[data-theme="light"] .ctr-action-btn.restart { background:rgba(37,99,235,0.08); }
|
||||||
|
[data-theme="light"] .ctr-action-btn.stop { background:rgba(220,38,38,0.08); }
|
||||||
|
[data-theme="light"] .ctr-action-btn.start { background:rgba(22,163,74,0.08); }
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user