Files

479 lines
17 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 []
import time
_stats_cache = {"data": None, "timestamp": 0}
def get_backup_stats():
"""Get backup stats from GDrive."""
global _stats_cache
if _stats_cache["data"] and (time.time() - _stats_cache["timestamp"] < 60):
return _stats_cache["data"]
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
_stats_cache["data"] = stats
_stats_cache["timestamp"] = time.time()
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"
)
global _stats_cache
_stats_cache["timestamp"] = 0 # Invalidate cache
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)