Sync from main server - 2026-04-16 13:42:46

This commit is contained in:
root
2026-04-16 13:42:46 +02:00
parent 68870eb3db
commit 81347bbdd2
3 changed files with 547 additions and 420 deletions

View File

@@ -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__)
@@ -46,12 +55,13 @@ def _stream_restore(job_id, cmd):
@app.route('/') @app.route('/')
@login_required @login_required
def dashboard(): def dashboard():
containers = get_containers() containers = get_containers()
running_count = sum(1 for c in containers if 'Up' in c.get('status', '')) running_count = sum(1 for c in containers if 'Up' in c.get('status', ''))
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 = get_all_users() # Users are still LOCAL (users on the platform host)
users = get_all_users()
return render_template('dashboard.html', return render_template('dashboard.html',
containers=containers, containers=containers,
running_count=running_count, running_count=running_count,
@@ -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,14 +85,13 @@ 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())
@app.route('/api/containers') @app.route('/api/containers')
@login_required @login_required
def api_containers(): def api_containers():
containers = get_all_root_containers() containers = get_all_root_containers()
running_count = sum(1 for c in containers if 'Up' in c.get('status', '')) running_count = sum(1 for c in containers if 'Up' in c.get('status', ''))
return jsonify({'containers': containers, 'running': running_count}) return jsonify({'containers': containers, 'running': running_count})
@@ -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
@@ -130,7 +161,7 @@ def api_user_disk(username):
@app.route('/api/users/create', methods=['POST']) @app.route('/api/users/create', methods=['POST'])
@login_required @login_required
def api_create_user(): def api_create_user():
data = request.get_json() or {} data = request.get_json() or {}
username = data.get('username', '').strip() username = data.get('username', '').strip()
password = data.get('password', '').strip() password = data.get('password', '').strip()
setup_docker = data.get('setup_docker', True) setup_docker = data.get('setup_docker', True)
@@ -151,7 +182,7 @@ def api_create_user():
@app.route('/api/users/delete', methods=['POST']) @app.route('/api/users/delete', methods=['POST'])
@login_required @login_required
def api_delete_user(): def api_delete_user():
data = request.get_json() or {} data = request.get_json() or {}
username = data.get('username', '').strip() username = data.get('username', '').strip()
remove_home = data.get('remove_home', False) remove_home = data.get('remove_home', False)
@@ -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_path = f"/root/backups/{backup_file}" # Backup is on main server at /root/backups/
if not os.path.exists(backup_path): if RUNNING_ON_MAIN_SERVER:
return jsonify({'error': f'Backup not found: {backup_path}'}), 400 backup_path = f"/root/backups/{backup_file}"
if not os.path.exists(backup_path):
return jsonify({'error': f'Not found: {backup_path}'}), 400
else:
# We're on VM → need to pull backup from main server to /tmp/ first
backup_path = f"/tmp/{backup_file}"
if not os.path.exists(backup_path):
pull_cmd = (
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: else:
backup_path = f"/tmp/{backup_file}" # VM backup
if not os.path.exists(backup_path): if RUNNING_ON_MAIN_SERVER:
pull_cmd = ( # Pull from VM via tunnel
f"scp -i {VM_KEY} -P {VM_PORT} " backup_path = f"/tmp/{backup_file}"
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 " if not os.path.exists(backup_path):
f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} {backup_path}" pull_cmd = (
) f"scp -i {VM_KEY} -P {VM_PORT} "
result = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True) f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
if result.returncode != 0: f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} "
return jsonify({'error': f'Failed to pull from VM: {result.stderr}'}), 500 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 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=$? ; "

View File

@@ -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:
continue for sock_path in stdout.split('\n'):
parts = line.split('|') sock_path = sock_path.strip()
containers.append({ if not sock_path:
'name': parts[0].strip(), continue
'status': parts[1].strip(), # Determine username from uid
'image': parts[2].strip(), try:
'ports': parts[3].strip() if len(parts) > 3 else '', uid = sock_path.split('/run/user/')[1].split('/')[0]
'owner': 'root', except (IndexError, ValueError):
}) continue
name_out, _ = _ssh_main(f"getent passwd {uid} | cut -d: -f1")
username = name_out.strip() or f"uid{uid}"
ctr_out, _ = _ssh_main(
f"DOCKER_HOST=unix://{sock_path} "
f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null"
)
containers.extend(_parse_containers(ctr_out, owner=username))
return containers 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,40 +216,63 @@ 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',
'memory': mem_out or 'N/A', 'memory': mem_out or 'N/A',
'mem_pct': mem_pct or '0', 'mem_pct': mem_pct or '0',
'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',
} }

File diff suppressed because it is too large Load Diff