Initial commit: BrainKP Backup UI
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
#!/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=""):
|
||||
"""List contents of a rclone remote path."""
|
||||
cmd = ["rclone", "lsjson", f"{remote}:{path}", "--drive-root-folder-id", "1ey-5aABgHDtirxeJarWy0ZmeqQPVTYtm"]
|
||||
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
|
||||
repo_items = list_rclone_contents("gdrive", "Git/repos")
|
||||
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")
|
||||
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:
|
||||
name = item.get("Name", "")
|
||||
parts = name.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
|
||||
subprocess.Popen([scripts[script]], stdout=open("/root/backup.log", "a"), stderr=subprocess.STDOUT)
|
||||
return jsonify({"ok": True, "message": f"{script} backup started"})
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 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)
|
||||
Reference in New Issue
Block a user