Sync from main server - 2026-04-18 18:47:38
This commit is contained in:
249
platform/app.py
249
platform/app.py
@@ -17,7 +17,9 @@ from modules.backups import (
|
||||
get_local_backups, get_vm_backups,
|
||||
get_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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user