468 lines
16 KiB
Python
468 lines
16 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": []}
|
|
|
|
try:
|
|
with open(LOG_FILE, "r") as f:
|
|
lines = f.readlines()
|
|
except Exception:
|
|
return {"last_run": None, "entries": []}
|
|
|
|
import re
|
|
from datetime import datetime
|
|
|
|
pattern = re.compile(r"^\[(.*?)\]\s+(Backup\s+[\w\s-]+?)\s+(started|finished)$")
|
|
events = []
|
|
active_runs = {}
|
|
|
|
for line in lines[-1000:]:
|
|
line_str = line.strip()
|
|
match = pattern.match(line_str)
|
|
if match:
|
|
ts, name, action = match.groups()
|
|
events.append({
|
|
"ts": ts,
|
|
"name": name,
|
|
"action": action,
|
|
"has_error": False,
|
|
"error_msg": None
|
|
})
|
|
idx = len(events) - 1
|
|
if action == "started":
|
|
active_runs[name] = idx
|
|
elif action == "finished":
|
|
if name in active_runs:
|
|
del active_runs[name]
|
|
else:
|
|
if "error" in line_str.lower() or "failed" in line_str.lower():
|
|
for name, idx in list(active_runs.items()):
|
|
events[idx]["has_error"] = True
|
|
events[idx]["error_msg"] = line_str
|
|
|
|
if not events:
|
|
return {"last_run": None, "entries": []}
|
|
|
|
sessions = []
|
|
current_session = None
|
|
|
|
def parse_ts(ts_str):
|
|
try:
|
|
cleaned = ts_str.replace("UTC ", "")
|
|
cleaned = " ".join(cleaned.split())
|
|
return datetime.strptime(cleaned, "%a %b %d %H:%M:%S %Y")
|
|
except:
|
|
return None
|
|
|
|
for ev in events:
|
|
ev_time = parse_ts(ev["ts"])
|
|
is_new_session = False
|
|
if not current_session:
|
|
is_new_session = True
|
|
else:
|
|
sess_time = parse_ts(current_session["timestamp"])
|
|
if ev_time and sess_time:
|
|
diff = (ev_time - sess_time).total_seconds()
|
|
if diff > 900:
|
|
is_new_session = True
|
|
else:
|
|
is_new_session = True
|
|
|
|
if is_new_session:
|
|
if current_session:
|
|
sessions.append(current_session)
|
|
current_session = {
|
|
"timestamp": ev["ts"],
|
|
"scripts": [],
|
|
"has_error": False
|
|
}
|
|
|
|
script_entry = next((s for s in current_session["scripts"] if s["name"] == ev["name"]), None)
|
|
if not script_entry:
|
|
script_entry = {"name": ev["name"], "status": "running"}
|
|
current_session["scripts"].append(script_entry)
|
|
|
|
if ev["action"] == "finished":
|
|
script_entry["status"] = "ok"
|
|
elif ev["action"] == "started":
|
|
script_entry["status"] = "running"
|
|
|
|
if ev["has_error"]:
|
|
script_entry["status"] = "error"
|
|
current_session["has_error"] = True
|
|
current_session["error"] = ev["error_msg"]
|
|
|
|
if current_session:
|
|
sessions.append(current_session)
|
|
|
|
sessions.reverse()
|
|
|
|
return {
|
|
"last_run": sessions[0] if sessions else None,
|
|
"entries": sessions[: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)
|