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

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

View File

@@ -17,7 +17,9 @@ from modules.backups import (
get_local_backups, get_vm_backups,
get_all_stats, get_system_info,
get_rootless_user_containers_remote,
container_action,
container_action, get_container_status,
audit_backup, delete_backup,
get_backup_log_entries, get_backup_script_path,
)
from modules.commands import run_command
from modules.users import (
@@ -28,7 +30,8 @@ from modules.users import (
app = Flask(__name__)
app.secret_key = 'navitrends-secret-key-2025'
restore_jobs = {}
restore_jobs = {}
backup_jobs = {} # for manual backup runs
def _stream_restore(job_id, cmd):
@@ -49,6 +52,25 @@ def _stream_restore(job_id, cmd):
restore_jobs[job_id]['status'] = 'error'
def _stream_backup(job_id, script_path):
"""Run the backup script and stream its output into backup_jobs."""
backup_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()}
try:
proc = subprocess.Popen(
['bash', script_path],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1
)
for line in proc.stdout:
backup_jobs[job_id]['log'].append(line.rstrip())
proc.wait()
backup_jobs[job_id]['status'] = 'done' if proc.returncode == 0 else 'error'
backup_jobs[job_id]['returncode'] = proc.returncode
except Exception as e:
backup_jobs[job_id]['log'].append(f"ERROR: {e}")
backup_jobs[job_id]['status'] = 'error'
# ─────────────────────────────────────────────
# DASHBOARD
# ─────────────────────────────────────────────
@@ -60,9 +82,8 @@ def dashboard():
backups = get_local_backups()
vm_backups = get_vm_backups()
system = get_system_info()
# Users are still LOCAL (users on the platform host)
users = get_all_users()
return render_template('dashboard.html',
return render_template('pages/dashboard.html',
containers=containers,
running_count=running_count,
backups=backups,
@@ -70,7 +91,82 @@ def dashboard():
main_server=MAIN_SERVER_IP,
system=system,
users=users,
running_on_main=RUNNING_ON_MAIN_SERVER)
running_on_main=RUNNING_ON_MAIN_SERVER,
active_page='dashboard',
page_title='Dashboard',
page_subtitle=MAIN_SERVER_IP)
@app.route('/containers')
@login_required
def containers_page():
return render_template(
'pages/containers.html',
main_server=MAIN_SERVER_IP,
active_page='containers',
page_title='All Containers',
page_subtitle='main server · all users'
)
@app.route('/backups')
@login_required
def backups_page():
return render_template(
'pages/backups.html',
backups=get_local_backups(),
vm_backups=get_vm_backups(),
main_server=MAIN_SERVER_IP,
active_page='backups',
page_title='Backup Management',
page_subtitle='local & VM'
)
@app.route('/restore')
@login_required
def restore_page():
prefill = {
'source': request.args.get('source', '').strip(),
'file': request.args.get('file', '').strip(),
}
return render_template(
'pages/restore.html',
backups=get_local_backups(),
vm_backups=get_vm_backups(),
restore_prefill=prefill,
main_server=MAIN_SERVER_IP,
active_page='restore',
page_title='Restore',
page_subtitle='backup → target'
)
@app.route('/users')
@login_required
def users_page():
return render_template(
'pages/users.html',
users=get_all_users(),
main_server=MAIN_SERVER_IP,
active_page='users',
page_title='User Management',
page_subtitle='linux + docker'
)
@app.route('/settings')
@login_required
def settings_page():
return render_template(
'pages/settings.html',
main_server=MAIN_SERVER_IP,
system=get_system_info(),
running_on_main=RUNNING_ON_MAIN_SERVER,
active_page='settings',
page_title='Settings',
page_subtitle='platform config'
)
# ─────────────────────────────────────────────
@@ -107,6 +203,20 @@ def api_containers_all():
return jsonify({'containers': all_ctrs, 'running': running})
@app.route('/api/nav-summary')
@login_required
def api_nav_summary():
"""Lightweight counts for sidebar badges on every page (one round trip)."""
root_ctrs = get_all_root_containers()
user_ctrs = get_rootless_user_containers_remote()
all_ctrs = root_ctrs + user_ctrs
users = get_all_users()
return jsonify({
'container_count': len(all_ctrs),
'user_count': len(users),
})
# ─────────────────────────────────────────────
# API — container actions
# ─────────────────────────────────────────────
@@ -115,7 +225,8 @@ def api_containers_all():
def api_container_action():
"""
POST JSON: { "name": "container-name", "action": "start|stop|restart" }
Runs the action on the main server (via SSH if on VM).
Runs the action, then immediately returns the NEW container status so the
UI can update without waiting for the next 15-second refresh cycle.
"""
data = request.get_json() or {}
name = data.get('name', '').strip()
@@ -125,7 +236,28 @@ def api_container_action():
return jsonify({'success': False, 'message': 'name and action required'}), 400
success, output = container_action(name, action)
return jsonify({'success': success, 'output': output})
# Give Docker a moment to settle, then fetch the real status
time.sleep(1.5)
status_info = get_container_status(name)
return jsonify({
'success': success,
'output': output,
'new_status': status_info['status'], # 'running' | 'stopped' | 'unknown'
'new_status_raw': status_info['raw'],
})
# ─────────────────────────────────────────────
# API — single container status (for polling)
# ─────────────────────────────────────────────
@app.route('/api/container/status/<name>')
@login_required
def api_container_status(name):
"""Quick single-container status check."""
status_info = get_container_status(name)
return jsonify(status_info)
# ─────────────────────────────────────────────
@@ -137,6 +269,100 @@ def api_backups():
return jsonify({'local': get_local_backups(), 'vm': get_vm_backups()})
@app.route('/api/backups/log')
@login_required
def api_backup_log():
"""Return the last N backup log entries."""
limit = int(request.args.get('limit', 20))
entries = get_backup_log_entries(limit)
return jsonify({'entries': entries})
# ─────────────────────────────────────────────
# API — backup health audit
# ─────────────────────────────────────────────
@app.route('/api/backups/audit', methods=['POST'])
@login_required
def api_backup_audit():
"""
POST JSON: { "backup_file": "myapps-backup-…tar.gz", "source": "local"|"vm" }
Returns full audit report.
"""
data = request.get_json() or {}
bfile = data.get('backup_file', '').strip()
source = data.get('source', 'local').strip()
if not bfile:
return jsonify({'error': 'backup_file required'}), 400
result = audit_backup(bfile, source)
return jsonify(result)
# ─────────────────────────────────────────────
# API — delete backup
# ─────────────────────────────────────────────
@app.route('/api/backups/delete', methods=['POST'])
@login_required
def api_backup_delete():
"""
POST JSON: { "backup_file": "myapps-backup-…tar.gz", "source": "local"|"vm" }
"""
data = request.get_json() or {}
bfile = data.get('backup_file', '').strip()
source = data.get('source', 'local').strip()
if not bfile:
return jsonify({'success': False, 'message': 'backup_file required'}), 400
success, message = delete_backup(bfile, source)
return jsonify({'success': success, 'message': message})
# ─────────────────────────────────────────────
# API — manual backup trigger
# ─────────────────────────────────────────────
@app.route('/api/backups/run', methods=['POST'])
@login_required
def api_backup_run():
"""
Trigger a manual backup run on the main server.
Returns a job_id so the UI can poll /api/backups/run/status/<job_id>.
Only works when running on the main server (where the backup script lives).
"""
if not RUNNING_ON_MAIN_SERVER:
return jsonify({
'success': False,
'message': 'Manual backup can only be triggered from the main server platform.'
}), 400
script = '/root/backup-myapps.sh'
if not os.path.exists(script):
return jsonify({
'success': False,
'message': f'Backup script not found at {script}'
}), 500
job_id = str(uuid.uuid4())
t = threading.Thread(target=_stream_backup, args=(job_id, script), daemon=True)
t.start()
return jsonify({'success': True, 'job_id': job_id, 'status': 'started'})
@app.route('/api/backups/run/status/<job_id>')
@login_required
def api_backup_run_status(job_id):
"""Poll manual backup job status."""
job = backup_jobs.get(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify({
'status': job['status'],
'log': job['log'],
'elapsed': round(time.time() - job.get('started', time.time()))
})
# ─────────────────────────────────────────────
# API — users (LOCAL — users on this host)
# ─────────────────────────────────────────────
@@ -218,13 +444,11 @@ def restore_start():
# ── Resolve backup archive path ──────────────────────────────────────────
if backup_source == 'local':
# Backup is on main server at /root/backups/
if RUNNING_ON_MAIN_SERVER:
backup_path = f"/root/backups/{backup_file}"
if not os.path.exists(backup_path):
return jsonify({'error': f'Not found: {backup_path}'}), 400
else:
# We're on VM → need to pull backup from main server to /tmp/ first
backup_path = f"/tmp/{backup_file}"
if not os.path.exists(backup_path):
pull_cmd = (
@@ -237,9 +461,7 @@ def restore_start():
if res.returncode != 0:
return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500
else:
# VM backup
if RUNNING_ON_MAIN_SERVER:
# Pull from VM via tunnel
backup_path = f"/tmp/{backup_file}"
if not os.path.exists(backup_path):
pull_cmd = (
@@ -252,7 +474,6 @@ def restore_start():
if res.returncode != 0:
return jsonify({'error': f'Failed to pull from VM: {res.stderr}'}), 500
else:
# We're on VM → backup is local
backup_path = f"/backups/main-server/{backup_file}"
if not os.path.exists(backup_path):
return jsonify({'error': f'Not found: {backup_path}'}), 400
@@ -263,9 +484,6 @@ def restore_start():
if not os.path.exists(restore_script_local):
return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500
# ── Determine the actual target ──────────────────────────────────────────
# "local" always means THIS host — wherever the platform is currently deployed.
# Run the restore script directly, no SSH indirection needed.
if target == 'local':
hostname = os.uname().nodename
session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}"
@@ -282,7 +500,6 @@ def restore_start():
)
else:
# Explicit remote machine (custom IP)
if not remote_ip:
return jsonify({'error': 'remote_ip required'}), 400