import asyncio import json import os import time from pathlib import Path from typing import Dict, Any, List, Optional import yaml from backend.runner import runner from backend.config import load_settings, get_setting from backend.remote import run_remote_quick, build_ssh_prefix async def get_pangolin_stack_status() -> Dict[str, Any]: settings = load_settings() vps_host = settings.get("vps_host", "").strip() p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip() if not vps_host: return { "vps_configured": False, "installed": False, "path": p_dir, "compose_exists": False, "config_exists": False, "database": {"found": False, "path": None, "size_bytes": 0}, "containers": [], "running_count": 0, "total_count": 0, "api_online": False, "message": "Configure VPS Host and SSH credentials in Settings to manage Pangolin." } # Query Remote VPS for files, DB, and Docker status check_script = f""" eval P_DIR="{p_dir}" P_DIR="${{P_DIR/#~/$HOME}}" if [ ! -d "$P_DIR" ] || [ -z "$(ls -A "$P_DIR" 2>/dev/null)" ]; then DETECTED=$(docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}}}' $(docker ps -aq --filter "name=pangolin" 2>/dev/null) 2>/dev/null | head -n 1) if [ -z "$DETECTED" ] || [ ! -d "$DETECTED" ]; then for candidate in "$HOME/config" "/root/config" "$HOME/pangolin" "/root/pangolin" "$HOME" "/opt/pangolin" /home/*/pangolin /var/lib/pangolin /srv/pangolin /etc/pangolin; do if [ -d "$candidate" ] && [ -n "$(ls -A "$candidate" 2>/dev/null)" ]; then DETECTED="$candidate" break fi done fi if [ -n "$DETECTED" ] && [ -d "$DETECTED" ]; then P_DIR="$DETECTED" fi fi echo "RESOLVED_DIR:$P_DIR" [ -f "$P_DIR/docker-compose.yml" ] || [ -f "$P_DIR/compose.yaml" ] && echo "COMPOSE_EXISTS" || echo "COMPOSE_NO" [ -f "$P_DIR/config/config.yml" ] || [ -f "$P_DIR/config.yml" ] && echo "CONFIG_EXISTS" || echo "CONFIG_NO" # Check DB db=$(find "$P_DIR" -name "*.db" -o -name "*.sqlite" 2>/dev/null | head -n 1) if [ -n "$db" ]; then echo "DB_PATH:$db" wc -c "$db" 2>/dev/null | awk '{{print $1}}' else echo "DB_PATH:NONE" echo "0" fi # Docker containers docker ps -a --format '{{{{json .}}}}' 2>/dev/null || true """ ret, stdout, stderr = await run_remote_quick(check_script, settings) if ret != 0: return { "vps_configured": True, "connected": False, "installed": False, "path": resolved_path, "compose_exists": False, "config_exists": False, "database": {"found": False, "path": None, "size_bytes": 0}, "containers": [], "running_count": 0, "total_count": 0, "api_online": False, "error": stderr.strip() or "SSH connection to VPS failed." } lines = stdout.splitlines() resolved_path = p_dir for l in lines: if l.startswith("RESOLVED_DIR:"): resolved_path = l.split(":", 1)[1].strip() break compose_exists = any("COMPOSE_EXISTS" in l for l in lines) config_exists = any("CONFIG_EXISTS" in l for l in lines) db_path = None db_size = 0 for idx, l in enumerate(lines): if l.startswith("DB_PATH:") and not l.endswith(":NONE"): db_path = l.split(":", 1)[1].strip() if idx + 1 < len(lines) and lines[idx + 1].strip().isdigit(): db_size = int(lines[idx + 1].strip()) break # Parse Docker JSON lines containers = [] for l in lines: l = l.strip() if not l or not l.startswith("{"): continue try: data = json.loads(l) names = data.get("Names", "") image = data.get("Image", "") if any(k in names.lower() or k in image.lower() for k in ["pangolin", "gerbil", "traefik", "newt"]): containers.append({ "id": data.get("ID", ""), "name": names, "image": image, "status": data.get("Status", ""), "state": data.get("State", ""), "created": data.get("CreatedAt", ""), "ports": data.get("Ports", "") }) except Exception: continue running = sum(1 for c in containers if c.get("state") == "running") return { "vps_configured": True, "connected": True, "installed": compose_exists, "path": resolved_path, "compose_exists": compose_exists, "config_exists": config_exists, "database": { "found": db_path is not None, "path": db_path, "size_bytes": db_size }, "containers": containers, "running_count": running, "total_count": len(containers), "api_online": running > 0 } async def trigger_pangolin_update() -> Dict[str, Any]: settings = load_settings() vps_host = settings.get("vps_host", "").strip() p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip() if not vps_host: return {"success": False, "error": "No VPS configured. Set VPS Host in Settings."} ssh_prefix = build_ssh_prefix(settings) # Remote update script executed on the VPS remote_script = f""" eval P_DIR="{p_dir}" P_DIR="${{P_DIR/#~/$HOME}}" # Locate compose directory if [ -f "$P_DIR/docker-compose.yml" ] || [ -f "$P_DIR/compose.yaml" ]; then COMPOSE_DIR="$P_DIR" elif [ -f "$(dirname "$P_DIR")/docker-compose.yml" ] || [ -f "$(dirname "$P_DIR")/compose.yaml" ]; then COMPOSE_DIR="$(dirname "$P_DIR")" else COMPOSE_DIR="$P_DIR" fi echo "=== STEP 1: Connecting to Pangolin VPS ({vps_host}) ===" echo "Operating Directory: $COMPOSE_DIR" cd "$COMPOSE_DIR" || exit 1 # Detect docker compose version if docker compose version >/dev/null 2>&1; then DC="docker compose" elif command -v docker-compose >/dev/null 2>&1; then DC="docker-compose" else echo "ERROR: Neither 'docker compose' nor 'docker-compose' found on VPS." exit 1 fi echo "" echo "=== STEP 2: Pulling latest images on VPS ===" $DC pull echo "" echo "=== STEP 3: Applying stack updates & recreating containers ===" $DC up -d --remove-orphans echo "" echo "=== STEP 4: Verifying running container status ===" docker compose ps echo "" echo "=== STEP 5: Cleaning up unused images on VPS ===" docker image prune -f echo "" echo "=== Remote Pangolin Stack update completed successfully! ===" """ full_cmd = ssh_prefix + [remote_script] res = await runner.run_command_job( name=f"Update Remote Pangolin Stack ({vps_host})", cmd=full_cmd, job_type="update_pangolin", description=f"Pull latest docker images and recreate Pangolin stack on remote VPS ({vps_host})" ) return res async def container_action(container_name: str, action: str) -> Dict[str, Any]: if action not in ["start", "stop", "restart"]: return {"success": False, "error": "Invalid action"} settings = load_settings() vps_host = settings.get("vps_host", "").strip() if not vps_host: return {"success": False, "error": "No VPS configured."} ret, stdout, stderr = await run_remote_quick(f"docker {action} {container_name}", settings) if ret == 0: return {"success": True, "output": stdout.strip()} return {"success": False, "error": stderr.strip()} async def get_container_logs(container_name: str, lines: int = 150) -> Dict[str, Any]: settings = load_settings() vps_host = settings.get("vps_host", "").strip() if not vps_host: return {"success": False, "error": "No VPS configured."} ret, stdout, stderr = await run_remote_quick(f"docker logs --tail {lines} {container_name} 2>&1", settings) return {"success": True, "logs": stdout or stderr or "No logs available."} async def get_stack_config_files() -> Dict[str, Any]: settings = load_settings() vps_host = settings.get("vps_host", "").strip() p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip() if not vps_host: return {} candidates = ["docker-compose.yml", "compose.yaml", ".env", "config/config.yml", "config.yml"] result = {} for c in candidates: ret, stdout, _ = await run_remote_quick(f'[ -f "{p_dir}/{c}" ] && cat "{p_dir}/{c}"', settings) if ret == 0 and stdout: result[c] = stdout return result async def save_stack_config_file(filename: str, content: str) -> Dict[str, Any]: settings = load_settings() vps_host = settings.get("vps_host", "").strip() p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip() if not vps_host: return {"success": False, "error": "No VPS configured."} if ".." in filename or filename.startswith("/"): return {"success": False, "error": "Invalid filename path."} # Validate YAML locally before pushing to VPS if filename.endswith((".yml", ".yaml")): try: yaml.safe_load(content) except yaml.YAMLError as ye: return {"success": False, "error": f"Invalid YAML syntax: {str(ye)}"} # Push to remote VPS with backup of old file safe_target = f"{p_dir}/{filename}" remote_write_script = f""" mkdir -p "$(dirname '{safe_target}')" if [ -f '{safe_target}' ]; then cp '{safe_target}' '{safe_target}.bak.{int(time.time())}' fi cat << 'PANGOLIN_EOF_SAVE' > '{safe_target}' {content} PANGOLIN_EOF_SAVE """ ret, _, stderr = await run_remote_quick(remote_write_script, settings) if ret == 0: return {"success": True, "message": f"Saved {filename} on remote VPS with automatic snapshot backup."} return {"success": False, "error": f"Failed to save on remote VPS: {stderr}"}