402 lines
14 KiB
Python
402 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Backup UI - Flask backend
|
|
Serves the backup dashboard and handles restore operations.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import json
|
|
import tarfile
|
|
import re
|
|
from datetime import datetime
|
|
from flask import Flask, render_template, jsonify, request, send_file
|
|
|
|
app = Flask(__name__)
|
|
APP_DIR = "/root/Apps/backup-ui"
|
|
BACKUP_DIR = "/tmp/restore-temp"
|
|
LOG_FILE = "/root/backup.log"
|
|
PASSWORD = "@@Gi05Br;;" # Change this!
|
|
|
|
os.makedirs(BACKUP_DIR, exist_ok=True)
|
|
|
|
# ──────────────────────────────────────────────
|
|
# AUTH
|
|
# ──────────────────────────────────────────────
|
|
|
|
@app.route("/api/login", methods=["POST"])
|
|
def login():
|
|
data = request.json or {}
|
|
if data.get("password") == PASSWORD:
|
|
return jsonify({"ok": True})
|
|
return jsonify({"ok": False, "error": "Invalid password"}), 401
|
|
|
|
# ──────────────────────────────────────────────
|
|
# BACKUP STATUS
|
|
# ──────────────────────────────────────────────
|
|
|
|
def get_backup_log():
|
|
"""Parse the backup log to get last run info."""
|
|
if not os.path.exists(LOG_FILE):
|
|
return {"last_run": None, "entries": []}
|
|
|
|
with open(LOG_FILE, "r") as f:
|
|
lines = f.readlines()
|
|
|
|
entries = []
|
|
current = {}
|
|
|
|
for line in lines[-500:]: # last 500 lines
|
|
if "==========" in line:
|
|
if current:
|
|
entries.append(current)
|
|
ts = line.strip().split("==========")[1].strip()
|
|
current = {"timestamp": ts, "scripts": []}
|
|
elif "[Backup" in line and "started" in line:
|
|
script = line.strip().split("] ")[1].split(" started")[0]
|
|
current["scripts"].append({"name": script, "status": "running"})
|
|
elif "[Backup" in line and "finished" in line:
|
|
script = line.strip().split("] ")[1].split(" finished")[0]
|
|
for s in current.get("scripts", []):
|
|
if s["name"] == script:
|
|
s["status"] = "ok"
|
|
elif "error" in line.lower() or "failed" in line.lower():
|
|
current["has_error"] = True
|
|
current["error"] = line.strip()
|
|
|
|
if current:
|
|
entries.append(current)
|
|
|
|
entries.reverse()
|
|
return {
|
|
"last_run": entries[0] if entries else None,
|
|
"entries": entries[:10]
|
|
}
|
|
|
|
def list_rclone_contents(remote, path="", depth=1):
|
|
"""List contents of a rclone remote path."""
|
|
cmd = ["rclone", "lsjson", f"{remote}:{path}",
|
|
"--drive-root-folder-id", "1ey-5aABgHDtirxeJarWy0ZmeqQPVTYtm",
|
|
"--max-depth", str(depth)]
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
if result.returncode == 0:
|
|
return json.loads(result.stdout)
|
|
except:
|
|
pass
|
|
return []
|
|
|
|
def get_backup_stats():
|
|
"""Get backup stats from GDrive."""
|
|
stats = {
|
|
"apps": {"count": 0, "latest": None, "size": 0},
|
|
"bd": {"count": 0, "latest": None, "size": 0},
|
|
"git": {"count": 0, "latest": None, "size": 0},
|
|
"repos": {"count": 0, "latest": None, "size": 0}
|
|
}
|
|
|
|
# Apps
|
|
try:
|
|
items = list_rclone_contents("gdrive", "Apps")
|
|
stats["apps"]["count"] = len(items)
|
|
if items:
|
|
items.sort(key=lambda x: x.get("ModTime", ""), reverse=True)
|
|
stats["apps"]["latest"] = items[0].get("Name")
|
|
stats["apps"]["size"] = sum(i.get("Size", 0) for i in items)
|
|
except:
|
|
pass
|
|
|
|
# BD
|
|
try:
|
|
items = list_rclone_contents("gdrive", "BD")
|
|
stats["bd"]["count"] = len(items)
|
|
if items:
|
|
items.sort(key=lambda x: x.get("ModTime", ""), reverse=True)
|
|
stats["bd"]["latest"] = items[0].get("Name")
|
|
stats["bd"]["size"] = sum(i.get("Size", 0) for i in items)
|
|
except:
|
|
pass
|
|
|
|
# Git
|
|
try:
|
|
items = list_rclone_contents("gdrive", "Git")
|
|
git_items = [i for i in items if "gitea-data" in i.get("Name", "")]
|
|
stats["git"]["count"] = len(git_items)
|
|
if git_items:
|
|
git_items.sort(key=lambda x: x.get("ModTime", ""), reverse=True)
|
|
stats["git"]["latest"] = git_items[0].get("Name")
|
|
stats["git"]["size"] = sum(i.get("Size", 0) for i in git_items)
|
|
|
|
# Repos
|
|
raw_repo_items = list_rclone_contents("gdrive", "Git/repos", depth=2)
|
|
repo_items = [i for i in raw_repo_items if not i.get("IsDir")]
|
|
stats["repos"]["count"] = len(repo_items)
|
|
if repo_items:
|
|
repo_items.sort(key=lambda x: x.get("ModTime", ""), reverse=True)
|
|
stats["repos"]["latest"] = repo_items[0].get("Name")
|
|
stats["repos"]["size"] = sum(i.get("Size", 0) for i in repo_items)
|
|
except:
|
|
pass
|
|
|
|
return stats
|
|
|
|
@app.route("/api/status")
|
|
def status():
|
|
log = get_backup_log()
|
|
stats = get_backup_stats()
|
|
return jsonify({"log": log, "stats": stats})
|
|
|
|
# ──────────────────────────────────────────────
|
|
# LIST BACKUPS
|
|
# ──────────────────────────────────────────────
|
|
|
|
@app.route("/api/backups/<backup_type>")
|
|
def list_backups(backup_type):
|
|
remotes = {
|
|
"apps": "gdrive:Apps",
|
|
"bd": "gdrive:BD",
|
|
"git": "gdrive:Git",
|
|
"repos": "gdrive:Git/repos"
|
|
}
|
|
remote = remotes.get(backup_type)
|
|
if not remote:
|
|
return jsonify({"error": "Unknown type"}), 400
|
|
|
|
if backup_type == "repos":
|
|
items = list_rclone_contents("gdrive", "Git/repos", depth=2)
|
|
elif backup_type == "git":
|
|
items = list_rclone_contents("gdrive", "Git")
|
|
items = [i for i in items if "gitea-data" in i.get("Name", "")]
|
|
else:
|
|
items = list_rclone_contents("gdrive", backup_type.capitalize())
|
|
|
|
# Group repos by owner
|
|
if backup_type == "repos":
|
|
grouped = {}
|
|
for item in items:
|
|
if item.get("IsDir"): continue
|
|
path = item.get("Path", "")
|
|
parts = path.split("/")
|
|
if len(parts) >= 2:
|
|
owner = parts[0]
|
|
repo = "/".join(parts[1:])
|
|
if owner not in grouped:
|
|
grouped[owner] = []
|
|
grouped[owner].append({"name": repo, "size": item.get("Size", 0), "modified": item.get("ModTime", "")})
|
|
else:
|
|
grouped["other"] = grouped.get("other", [])
|
|
grouped["other"].append({"name": name, "size": item.get("Size", 0), "modified": item.get("ModTime", "")})
|
|
return jsonify({"items": grouped, "type": "repos"})
|
|
|
|
items.sort(key=lambda x: x.get("ModTime", ""), reverse=True)
|
|
return jsonify({"items": [{"name": i.get("Name"), "size": i.get("Size", 0), "modified": i.get("ModTime", "")} for i in items], "type": backup_type})
|
|
|
|
# ──────────────────────────────────────────────
|
|
# RESTORE
|
|
# ──────────────────────────────────────────────
|
|
|
|
@app.route("/api/restore/<backup_type>", methods=["POST"])
|
|
def restore(backup_type):
|
|
data = request.json or {}
|
|
filename = data.get("filename")
|
|
target = data.get("target", "") # for repos: owner/repo
|
|
|
|
if not filename:
|
|
return jsonify({"error": "No filename provided"}), 400
|
|
|
|
remotes = {
|
|
"apps": "gdrive:Apps",
|
|
"bd": "gdrive:BD",
|
|
"git": "gdrive:Git",
|
|
"repos": "gdrive:Git/repos"
|
|
}
|
|
remote = remotes.get(backup_type)
|
|
if not remote:
|
|
return jsonify({"error": "Unknown type"}), 400
|
|
|
|
remote_path = f"{remote}/{target}/{filename}" if target and backup_type == "repos" else f"{remote}/{filename}"
|
|
|
|
# Download
|
|
local_file = f"{BACKUP_DIR}/{filename}"
|
|
cmd = ["rclone", "copy", remote_path, BACKUP_DIR, "--drive-root-folder-id", "1ey-5aABgHDtirxeJarWy0ZmeqQPVTYtm"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
|
|
if result.returncode != 0:
|
|
return jsonify({"error": f"Download failed: {result.stderr}"}), 500
|
|
|
|
return jsonify({"ok": True, "file": local_file, "size": os.path.getsize(local_file)})
|
|
|
|
@app.route("/api/restore/apps", methods=["POST"])
|
|
def restore_apps():
|
|
data = request.json or {}
|
|
filename = data.get("filename")
|
|
if not filename:
|
|
return jsonify({"error": "No filename"}), 400
|
|
|
|
src = f"/tmp/restore-temp/{filename}"
|
|
if not os.path.exists(src):
|
|
return jsonify({"error": "File not found locally"}), 404
|
|
|
|
# Extract to temp
|
|
extract_dir = f"/tmp/restore-apps-extract"
|
|
os.makedirs(extract_dir, exist_ok=True)
|
|
subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True)
|
|
|
|
# List what was extracted
|
|
extracted = os.listdir(extract_dir)
|
|
return jsonify({"ok": True, "extracted_to": extract_dir, "files": extracted, "instructions": {
|
|
"Camila": f"Copiar {extract_dir}/Camila/* para /root/Apps/Camila/",
|
|
"RAG": f"Copiar {extract_dir}/RAG/* para /root/Apps/RAG/",
|
|
"VOXDO": f"Copiar {extract_dir}/VOXDO/* para /root/Apps/VOXDO/"
|
|
}})
|
|
|
|
@app.route("/api/restore/bd", methods=["POST"])
|
|
def restore_bd():
|
|
data = request.json or {}
|
|
filename = data.get("filename")
|
|
db_type = data.get("db", "auto") # supabase or gitea
|
|
if not filename:
|
|
return jsonify({"error": "No filename"}), 400
|
|
|
|
src = f"/tmp/restore-temp/{filename}"
|
|
if not os.path.exists(src):
|
|
return jsonify({"error": "File not found locally"}), 404
|
|
|
|
extract_dir = f"/tmp/restore-bd-extract"
|
|
os.makedirs(extract_dir, exist_ok=True)
|
|
subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True)
|
|
|
|
# Find the SQL file
|
|
sql_files = []
|
|
for root, dirs, files in os.walk(extract_dir):
|
|
for f in files:
|
|
if f.endswith(".sql"):
|
|
sql_files.append(os.path.join(root, f))
|
|
|
|
return jsonify({
|
|
"ok": True,
|
|
"extracted_to": extract_dir,
|
|
"sql_files": sql_files,
|
|
"instructions": {
|
|
"supabase": f"docker exec -i supabase-db psql -U postgres -d postgres < {sql_files[0] if sql_files else '<file>'}",
|
|
"gitea": f"docker exec -i coolify-db psql -U postgres -d gitea < {sql_files[0] if sql_files else '<file>'}"
|
|
}
|
|
})
|
|
|
|
@app.route("/api/restore/git", methods=["POST"])
|
|
def restore_git():
|
|
data = request.json or {}
|
|
filename = data.get("filename")
|
|
if not filename:
|
|
return jsonify({"error": "No filename"}), 400
|
|
|
|
src = f"/tmp/restore-temp/{filename}"
|
|
if not os.path.exists(src):
|
|
return jsonify({"error": "File not found locally"}), 404
|
|
|
|
extract_dir = f"/tmp/restore-git-extract"
|
|
os.makedirs(extract_dir, exist_ok=True)
|
|
subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True)
|
|
|
|
# List extracted
|
|
extracted = []
|
|
for root, dirs, files in os.walk(extract_dir):
|
|
for f in files:
|
|
extracted.append(os.path.join(root, f).replace(extract_dir + "/", ""))
|
|
|
|
return jsonify({
|
|
"ok": True,
|
|
"extracted_to": extract_dir,
|
|
"files": extracted,
|
|
"instructions": {
|
|
"gitea_data": f"Copiar conteúdo para /var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/",
|
|
"gitea_db": f"docker exec -i <db-container> psql -U postgres -d gitea < <sql-file>"
|
|
}
|
|
})
|
|
|
|
@app.route("/api/restore/repo", methods=["POST"])
|
|
def restore_repo():
|
|
data = request.json or {}
|
|
owner = data.get("owner")
|
|
repo_name = data.get("repo")
|
|
filename = data.get("filename")
|
|
if not filename:
|
|
return jsonify({"error": "No filename"}), 400
|
|
|
|
src = f"/tmp/restore-temp/{filename}"
|
|
if not os.path.exists(src):
|
|
return jsonify({"error": "File not found locally"}), 404
|
|
|
|
extract_dir = f"/tmp/restore-repo-{owner}-{repo_name}"
|
|
os.makedirs(extract_dir, exist_ok=True)
|
|
|
|
# Extract tar.gz
|
|
result = subprocess.run(["tar", "-tzf", src], capture_output=True, text=True)
|
|
first_item = result.stdout.split("\n")[0] if result.stdout else ""
|
|
|
|
subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True)
|
|
|
|
# Find the .git folder
|
|
git_dir = None
|
|
for root, dirs, files in os.walk(extract_dir):
|
|
if ".git" in dirs:
|
|
git_dir = os.path.join(root, ".git")
|
|
work_tree = root
|
|
break
|
|
|
|
return jsonify({
|
|
"ok": True,
|
|
"extracted_to": extract_dir,
|
|
"git_dir": git_dir,
|
|
"work_tree": work_tree if 'work_tree' in dir() else extract_dir,
|
|
"instructions": {
|
|
"clone": f"git clone {git_dir} /tmp/{repo_name}-restored" if git_dir else "N/A",
|
|
"bare_restore": f"Copiar {git_dir} para /var/lib/docker/volumes/.../repositories/{owner}/{repo_name}.git" if git_dir else "N/A"
|
|
}
|
|
})
|
|
|
|
@app.route("/api/run-backup", methods=["POST"])
|
|
def run_backup():
|
|
data = request.json or {}
|
|
script = data.get("script")
|
|
scripts = {
|
|
"apps": "/root/backup-apps.sh",
|
|
"bd": "/root/backup-bd.sh",
|
|
"git": "/root/backup-git.sh",
|
|
"repos": "/root/backup-git-repos.sh"
|
|
}
|
|
if script not in scripts:
|
|
return jsonify({"error": "Unknown script"}), 400
|
|
|
|
# Run in background
|
|
import traceback
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[scripts[script]],
|
|
stdout=open("/root/backup.log", "a"),
|
|
stderr=subprocess.STDOUT,
|
|
cwd="/root"
|
|
)
|
|
return jsonify({"ok": True, "message": f"{script} backup started", "pid": proc.pid})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e), "trace": traceback.format_exc()}), 500
|
|
|
|
# ──────────────────────────────────────────────
|
|
# STATIC & LOGS
|
|
# ──────────────────────────────────────────────
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return app.send_static_file("index.html")
|
|
|
|
@app.route("/api/log")
|
|
def log():
|
|
if os.path.exists(LOG_FILE):
|
|
with open(LOG_FILE, "r") as f:
|
|
lines = f.readlines()
|
|
return jsonify({"lines": lines[-200:]})
|
|
return jsonify({"lines": []})
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=8766, debug=False)
|