Initial commit: BrainKP Backup UI

This commit is contained in:
2026-06-22 17:02:05 +00:00
commit 120a716e72
6 changed files with 1064 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY static/ static/
EXPOSE 8766
CMD ["gunicorn", "--bind", "0.0.0.0:8766", "--workers", "2", "--timeout", "120", "app:app"]
+388
View File
@@ -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)
+28
View File
@@ -0,0 +1,28 @@
version: '3.8'
services:
brainkp:
build: .
container_name: brainkp-backup
restart: unless-stopped
expose:
- "8766"
volumes:
- .:/app
- /tmp/restore-temp:/tmp/restore-temp
- /root/backup.log:/root/backup.log:ro
env_file:
- .env
networks:
- coolify
labels:
- "traefik.enable=true"
- "traefik.http.routers.brainkp.rule=Host(`bck.brainsteel.com.br`)"
- "traefik.http.routers.brainkp.entrypoints=https"
- "traefik.http.routers.brainkp.tls=true"
- "traefik.http.routers.brainkp.tls.certresolver=letsencrypt"
- "traefik.http.services.brainkp.loadbalancer.server.port=8766"
networks:
coolify:
external: true
+2
View File
@@ -0,0 +1,2 @@
flask>=3.0.0
gunicorn>=21.0.0
+600
View File
@@ -0,0 +1,600 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BrainSteel Backup</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#c9d1d9;
--muted:#8b949e;--accent:#58a6ff;--success:#3fb950;--danger:#f85149;
--warning:#d29922;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif
}
body{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;font-size:14px}
input,button,select{font-family:inherit}
/* LOGIN */
#login-wrap{display:flex;align-items:center;justify-content:center;height:100vh}
#login-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:40px;width:340px;text-align:center}
#login-box h1{font-size:20px;margin-bottom:8px;color:var(--text)}
#login-box p{color:var(--muted);font-size:12px;margin-bottom:24px}
#login-box input{width:100%;padding:12px 16px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none;transition:border .2s}
#login-box input:focus{border-color:var(--accent)}
#login-box button{width:100%;margin-top:12px;padding:12px;background:var(--accent);border:none;border-radius:8px;color:#fff;font-weight:600;cursor:pointer;font-size:14px;transition:opacity .2s}
#login-box button:hover{opacity:.85}
#login-error{color:var(--danger);font-size:12px;margin-top:10px;display:none}
/* MAIN LAYOUT */
#app{display:none;min-height:100vh;padding:16px;max-width:800px;margin:0 auto}
/* HEADER */
.header{display:flex;align-items:center;justify-content:space-between;padding:12px 0 20px;border-bottom:1px solid var(--border);margin-bottom:20px}
.header h1{font-size:18px;font-weight:700}
.header-right{display:flex;gap:8px;align-items:center}
.header-right button{padding:6px 12px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer;font-size:12px;transition:background .2s}
.header-right button:hover{background:var(--surface)}
.header-right button.danger{color:var(--danger);border-color:var(--danger)}
.header-right button.danger:hover{background:rgba(248,81,73,.1)}
/* SECTION */
.section{margin-bottom:28px}
.section-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
.section-title h2{font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.section-title .badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge.green{background:rgba(63,185,80,.15);color:var(--success)}
.badge.yellow{background:rgba(210,153,34,.15);color:var(--warning)}
.badge.red{background:rgba(248,81,73,.15);color:var(--danger)}
/* STATUS CARDS */
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;transition:border .2s,transform .1s}
.card:hover{border-color:var(--accent);transform:translateY(-1px)}
.card-icon{font-size:20px;margin-bottom:8px}
.card-name{font-size:13px;font-weight:600;margin-bottom:4px}
.card-meta{font-size:11px;color:var(--muted);line-height:1.5}
.card-meta span{display:block}
.card .run-btn{display:none;margin-top:10px;padding:6px 12px;background:var(--accent);border:none;border-radius:6px;color:#fff;font-size:11px;cursor:pointer;width:100%}
.card:hover .run-btn{display:block}
/* BACKUP LIST */
.backup-item{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--surface);border:1px solid var(--border);border-radius:8px;margin-bottom:8px;cursor:pointer;transition:border .2s}
.backup-item:hover{border-color:var(--accent)}
.backup-item .bi-left{display:flex;align-items:center;gap:10px;min-width:0}
.backup-item .bi-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}
.backup-item .bi-date{font-size:11px;color:var(--muted)}
.backup-item .bi-right{display:flex;align-items:center;gap:10px}
.backup-item .bi-size{font-size:11px;color:var(--muted);white-space:nowrap}
.backup-item .bi-size .icon{color:var(--success);font-size:12px}
.restore-btn{padding:5px 12px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--accent);font-size:11px;cursor:pointer;white-space:nowrap;transition:all .2s}
.restore-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
.restore-btn:disabled{opacity:.4;cursor:not-allowed}
/* REPO GRID */
.repo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}
.repo-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:pointer;transition:border .2s}
.repo-card:hover{border-color:var(--accent)}
.repo-card .owner{font-size:11px;color:var(--muted);margin-bottom:4px}
.repo-card .name{font-size:14px;font-weight:600;margin-bottom:6px;word-break:break-all}
.repo-card .meta{font-size:11px;color:var(--muted)}
.repo-card .versions{font-size:10px;color:var(--muted);margin-top:4px}
/* TABS */
.tabs{display:flex;gap:4px;margin-bottom:16px;overflow-x:auto;padding-bottom:4px}
.tab{padding:8px 16px;background:transparent;border:1px solid transparent;border-radius:8px;color:var(--muted);font-size:13px;cursor:pointer;white-space:nowrap;transition:all .2s}
.tab.active{background:var(--surface);border-color:var(--border);color:var(--text)}
.tab:hover:not(.active){color:var(--text)}
/* MODAL */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center;padding:16px}
.modal-overlay.show{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:12px;width:100%;max-width:480px;max-height:80vh;overflow-y:auto}
.modal-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.modal-header h3{font-size:15px}
.modal-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:18px;padding:4px;transition:color .2s}
.modal-close:hover{color:var(--text)}
.modal-body{padding:20px}
.modal-footer{padding:16px 20px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
/* FORM */
.form-group{margin-bottom:14px}
.form-group label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;transition:border .2s}
.form-group input:focus,.form-group select:focus{border-color:var(--accent)}
.form-group textarea{resize:vertical;min-height:80px;font-family:monospace;font-size:12px}
/* BUTTONS */
.btn{padding:8px 16px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text);font-size:13px;cursor:pointer;transition:all .2s}
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn-primary:hover{opacity:.85}
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}
.btn-danger:hover{opacity:.85}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* LOG VIEWER */
#log-viewer{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;font-family:monospace;font-size:11px;max-height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:var(--muted);display:none}
#log-viewer.show{display:block}
/* TOAST */
#toast{position:fixed;bottom:20px;right:20px;padding:12px 20px;background:var(--surface);border:1px solid var(--border);border-radius:8px;font-size:13px;z-index:200;display:none;animation:slideUp .3s ease}
#toast.show{display:block}
#toast.ok{border-color:var(--success)}
#toast.error{border-color:var(--danger)}
@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
/* LOADING */
.loading{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}
/* PROGRESS */
.progress-bar{width:100%;height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-top:8px;display:none}
.progress-bar.show{display:block}
.progress-bar .fill{height:100%;background:var(--accent);transition:width .3s}
/* EMPTY STATE */
.empty{text-align:center;padding:40px 20px;color:var(--muted);font-size:13px}
.empty-icon{font-size:32px;margin-bottom:8px;opacity:.5}
/* BACK BUTTON */
.back-btn{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;cursor:pointer;margin-bottom:16px;padding:4px 0;transition:color .2s}
.back-btn:hover{color:var(--text)}
/* DETAIL VIEW */
.detail-header{margin-bottom:20px}
.detail-title{font-size:18px;font-weight:700;margin-bottom:4px}
.detail-meta{font-size:12px;color:var(--muted)}
/* RESPONSIVE */
@media(max-width:600px){
#app{padding:12px}
.cards{grid-template-columns:1fr 1fr}
.card{padding:10px}
.tabs{overflow-x:auto;-webkit-overflow-scrolling:touch}
.backup-item .bi-name{max-width:120px}
.repo-grid{grid-template-columns:1fr}
.header h1{font-size:16px}
.modal{max-width:100%;border-radius:12px 12px 0 0;position:fixed;bottom:0;max-height:90vh}
}
</style>
</head>
<body>
<!-- LOGIN -->
<div id="login-wrap">
<div id="login-box">
<h1>🛡️ BrainSteel Backup</h1>
<p>Digite a senha para acessar</p>
<input type="password" id="password" placeholder="Senha" onkeydown="if(event.key==='Enter')doLogin()">
<button onclick="doLogin()">Entrar</button>
<div id="login-error">Senha incorreta</div>
</div>
</div>
<!-- MAIN APP -->
<div id="app">
<!-- HEADER -->
<div class="header">
<h1>🛡️ BrainSteel Backup</h1>
<div class="header-right">
<button onclick="showLog()">📋 Log</button>
<button class="danger" onclick="logout()">Sair</button>
</div>
</div>
<!-- LOG TOGGLE -->
<div id="log-viewer"></div>
<!-- STATUS SECTION -->
<div class="section">
<div class="section-title">
<h2>Status dos Backups</h2>
<span id="last-run" class="badge green"></span>
</div>
<div class="cards" id="cards"></div>
</div>
<!-- BACKUPS LIST -->
<div class="section">
<div class="section-title">
<h2>Backups</h2>
</div>
<div class="tabs" id="tabs">
<button class="tab active" onclick="switchTab('apps')">📦 Apps</button>
<button class="tab" onclick="switchTab('bd')">🗄️ Bancos</button>
<button class="tab" onclick="switchTab('git')">🐙 Git Full</button>
<button class="tab" onclick="switchTab('repos')">📁 Repos</button>
</div>
<div id="backup-list"></div>
</div>
</div>
<!-- RESTORE MODAL -->
<div class="modal-overlay" id="restore-modal">
<div class="modal">
<div class="modal-header">
<h3>🔄 Restaurar</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer" id="modal-footer">
<button class="btn" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary" id="modal-confirm" onclick="doRestore()">Restaurar</button>
</div>
</div>
</div>
<!-- TOAST -->
<div id="toast"></div>
<script>
const API = '/api';
let token = '';
let currentTab = 'apps';
let currentBackups = [];
let pendingRestore = null;
function showToast(msg, type='ok') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
setTimeout(() => t.className = '', 3000);
}
async function doLogin() {
const pw = document.getElementById('password').value;
const res = await fetch(`${API}/login`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({password: pw})
});
const d = await res.json();
if (d.ok) {
token = pw;
document.getElementById('login-wrap').style.display = 'none';
document.getElementById('app').style.display = 'block';
loadAll();
} else {
document.getElementById('login-error').style.display = 'block';
}
}
async function loadAll() {
await Promise.all([loadStatus(), loadBackups()]);
}
async function loadStatus() {
try {
const res = await fetch(`${API}/status`);
const d = await res.json();
// Last run badge
const lr = d.log.last_run;
const badge = document.getElementById('last-run');
if (lr) {
const ok = !lr.has_error && lr.scripts.every(s => s.status === 'ok');
badge.textContent = ok ? '✓ OK' : '⚠ Erro';
badge.className = `badge ${ok ? 'green' : 'red'}`;
} else {
badge.textContent = '—';
badge.className = 'badge yellow';
}
// Cards
const s = d.stats;
const cards = [
{id:'apps', icon:'📦', name:'Apps', desc:`${s.apps.count} backup${s.apps.count!==1?'s':''}`, meta: s.apps.latest ? `Último: ${timeAgo(s.apps.latest)}` : '', size: formatSize(s.apps.size)},
{id:'bd', icon:'🗄️', name:'Bancos', desc:`${s.bd.count} backup${s.bd.count!==1?'s':''}`, meta: s.bd.latest ? `Último: ${timeAgo(s.bd.latest)}` : '', size: formatSize(s.bd.size)},
{id:'git', icon:'🐙', name:'Git Full', desc:`${s.git.count} backup${s.git.count!==1?'s':''}`, meta: s.git.latest ? `Último: ${timeAgo(s.git.latest)}` : '', size: formatSize(s.git.size)},
{id:'repos', icon:'📁', name:'Repos', desc:`${s.repos.count} backup${s.repos.count!==1?'s':''}`, meta: s.repos.latest ? `Último: ${timeAgo(s.repos.latest)}` : '', size: formatSize(s.repos.size)},
];
document.getElementById('cards').innerHTML = cards.map(c => `
<div class="card" onclick="switchTab('${c.id}')">
<div class="card-icon">${c.icon}</div>
<div class="card-name">${c.name}</div>
<div class="card-meta">
<span>${c.desc}</span>
<span>${c.meta}</span>
<span>${c.size}</span>
</div>
<button class="run-btn" onclick="event.stopPropagation();runBackup('${c.id}')">▶ Executar Backup</button>
</div>
`).join('');
} catch(e) { console.error(e); }
}
async function loadBackups() {
const res = await fetch(`${API}/backups/${currentTab}`);
const d = await res.json();
currentBackups = d.items || [];
if (currentTab === 'repos') {
renderRepos(currentBackups);
} else {
renderBackupList(currentBackups);
}
}
function renderBackupList(items) {
const list = document.getElementById('backup-list');
if (!items.length) {
list.innerHTML = '<div class="empty"><div class="empty-icon">📭</div>Nenhum backup encontrado</div>';
return;
}
list.innerHTML = items.map(item => `
<div class="backup-item">
<div class="bi-left">
<span class="bi-name">${item.name}</span>
<span class="bi-date">${timeAgo(item.modified)}</span>
</div>
<div class="bi-right">
<span class="bi-size">${formatSize(item.size)}</span>
<button class="restore-btn" onclick="openRestoreModal('${currentTab}', '${item.name}', '${item.size}')">Restaurar</button>
</div>
</div>
`).join('');
}
function renderRepos(grouped) {
const list = document.getElementById('backup-list');
const owners = Object.keys(grouped);
if (!owners.length) {
list.innerHTML = '<div class="empty"><div class="empty-icon">📭</div>Nenhum repositório encontrado</div>';
return;
}
list.innerHTML = `<div class="back-btn" onclick="showAllRepos()"><span>←</span> Mostrar todos</div>` +
owners.map(owner => `
<div style="margin-bottom:16px">
<div style="font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:600">👤 ${owner}</div>
<div class="repo-grid">
${grouped[owner].map(repo => `
<div class="repo-card" onclick="openRestoreRepo('${owner}', '${repo.name}', ${repo.size})">
<div class="owner">${owner}</div>
<div class="name">${repo.name.replace('.git.tar.gz','')}</div>
<div class="meta">${formatSize(repo.size)}</div>
</div>
`).join('')}
</div>
</div>
`).join('');
}
function showAllRepos() {
// Flatten and show all repos
const res = fetch(`${API}/backups/repos`).then(r => r.json()).then(d => {
const flat = [];
for (const [owner, repos] of Object.entries(d.items || {})) {
for (const repo of repos) {
flat.push({owner, ...repo});
}
}
document.getElementById('backup-list').innerHTML = flat.map(r => `
<div class="backup-item">
<div class="bi-left">
<span class="bi-name">${r.owner}/${r.name.replace('.git.tar.gz','')}</span>
</div>
<div class="bi-right">
<span class="bi-size">${formatSize(r.size)}</span>
<button class="restore-btn" onclick="openRestoreRepo('${r.owner}', '${r.name}')">Restaurar</button>
</div>
</div>
`).join('');
});
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[onclick="switchTab('${tab}')"]`).classList.add('active');
loadBackups();
}
async function runBackup(type) {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span> Rodando...';
try {
const res = await fetch(`${API}/run-backup`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({script: type})
});
const d = await res.json();
if (d.ok) {
showToast(`${type} backup iniciado ✓`);
setTimeout(loadAll, 3000);
} else {
showToast(d.error, 'error');
}
} catch(e) { showToast('Erro ao iniciar backup', 'error'); }
btn.disabled = false;
btn.innerHTML = '▶ Executar Backup';
}
let restoreTarget = null;
function openRestoreModal(type, filename, size) {
restoreTarget = {type, filename};
const body = document.getElementById('modal-body');
body.innerHTML = `
<div class="form-group">
<label>Tipo</label>
<input type="text" value={getTypeLabel(type)} disabled>
</div>
<div class="form-group">
<label>Arquivo</label>
<input type="text" value="${filename}" disabled>
</div>
<div class="form-group">
<label>Tamanho</label>
<input type="text" value="${formatSize(size)}" disabled>
</div>
<div class="form-group">
<label>Instruções pós-restore</label>
<textarea id="restore-instructions" disabled>Baixando e preparando...</textarea>
</div>
<div class="progress-bar" id="dl-progress"><div class="fill" style="width:0%"></div></div>
`;
document.getElementById('modal-confirm').style.display = 'inline-block';
document.getElementById('restore-modal').classList.add('show');
// Start download
fetch(`${API}/restore/${type}`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({filename})
}).then(r => r.json()).then(d => {
if (d.ok) {
// Now get instructions based on type
let instructions = '';
if (type === 'apps') {
instructions = `📦 Apps restaurado em: ${d.extracted_to}\n\n` +
`Para aplicar:\n` +
` cp -r ${d.extracted_to}/Camila/* /root/Apps/Camila/\n` +
` cp -r ${d.extracted_to}/RAG/* /root/Apps/RAG/\n` +
` cp -r ${d.extracted_to}/VOXDO/* /root/Apps/VOXDO/\n\n` +
`Depois reinicie os serviços.`;
} else if (type === 'bd') {
instructions = `🗄️ Backup BD restaurado em: ${d.extracted_to}\n\n` +
`SQL: ${d.sql_files?.join(', ') || 'não encontrado'}\n\n` +
`Para restaurar:\n` +
` cat ${d.sql_files?.[0] || '<sql-file>'} | docker exec -i <container> psql -U postgres -d <db>`;
} else if (type === 'git') {
instructions = `🐙 Backup Git restaurado em: ${d.extracted_to}\n\n` +
`Arquivos: ${(d.files || []).slice(0,5).join(', ')}...\n\n` +
`Para aplicar:\n` +
` cp -r ${d.extracted_to}/* /var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/`;
}
document.getElementById('restore-instructions').value = instructions || 'Pronto para uso.';
} else {
document.getElementById('restore-instructions').value = `Erro: ${d.error}`;
}
});
}
function openRestoreRepo(owner, repoName, size) {
restoreTarget = {type: 'repo', owner, repoName};
const body = document.getElementById('modal-body');
body.innerHTML = `
<div class="form-group">
<label>Owner</label>
<input type="text" value="${owner}" disabled>
</div>
<div class="form-group">
<label>Repositório</label>
<input type="text" value="${repoName.replace('.git.tar.gz','')}" disabled>
</div>
<div class="form-group">
<label>Tamanho</label>
<input type="text" value="${formatSize(size)}" disabled>
</div>
<div class="form-group">
<label>Instruções</label>
<textarea id="restore-instructions" disabled>Baixando e preparando...</textarea>
</div>
`;
document.getElementById('restore-modal').classList.add('show');
fetch(`${API}/restore/repo`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({owner, repo: repoName.replace('.git.tar.gz',''), filename: repoName})
}).then(r => r.json()).then(d => {
if (d.ok) {
const repo = repoName.replace('.git.tar.gz','');
document.getElementById('restore-instructions').value =
`📁 Repo restaurado em: ${d.extracted_to}\n\n` +
`Git dir: ${d.git_dir || 'não encontrado'}\n\n` +
`Para restaurar no Gitea:\n` +
` # Pare o Gitea\n` +
` docker stop <gitea-container>\n` +
` # Copie o repo (ajuste o path)\n` +
` cp -r ${d.extracted_to}/* /var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/git/repositories/${owner}/\n` +
` # Reinicie o Gitea\n` +
` docker start <gitea-container>`;
} else {
document.getElementById('restore-instructions').value = `Erro: ${d.error}`;
}
});
}
function closeModal() {
document.getElementById('restore-modal').classList.remove('show');
restoreTarget = null;
}
async function doRestore() {
// For now, just close - instructions are shown
showToast('Arquivo baixado! Veja as instruções no textarea acima.');
closeModal();
}
async function showLog() {
const lv = document.getElementById('log-viewer');
if (lv.classList.contains('show')) {
lv.classList.remove('show');
return;
}
const res = await fetch(`${API}/log`);
const d = await res.json();
lv.textContent = d.lines.join('');
lv.classList.add('show');
lv.scrollTop = lv.scrollHeight;
}
function logout() {
document.getElementById('app').style.display = 'none';
document.getElementById('login-wrap').style.display = 'flex';
document.getElementById('password').value = '';
token = '';
}
// HELPERS
function formatSize(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function timeAgo(dateStr) {
if (!dateStr) return '—';
// dateStr can be filename (20260622) or ModTime
const m = dateStr.match(/(\d{4})(\d{2})(\d{2})/);
if (m) {
const d = new Date(m[1], m[2]-1, m[3]);
const diff = Date.now() - d.getTime();
const days = Math.floor(diff / 86400000);
if (days === 0) return 'Hoje';
if (days === 1) return 'Ontem';
return `${days} dia${days>1?'s':''}`;
}
try {
const d = new Date(dateStr);
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d`;
return d.toLocaleDateString('pt-BR');
} catch { return dateStr; }
}
function getTypeLabel(type) {
return {apps:'📦 Apps', bd:'🗄️ Banco de Dados', git:'🐙 Git Full', repos:'📁 Repositórios'}[type] || type;
}
// Auto-refresh every 30s
setInterval(() => {
if (document.getElementById('app').style.display !== 'none') {
loadStatus();
}
}, 30000);
</script>
</body>
</html>
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# ---------------------------------------------------------
# BRAINKP: SCRIPT DE DEPLOY TOTAL & SINCRONIZAÇÃO AUTOMÁTICA
# ---------------------------------------------------------
# Cores e Formatação Premium
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático de Deploy BrainKP...${NC}"
# 1. Sincronização com Repositório (Git - Gitea)
echo -e "${YELLOW}📝 Sincronizando código com o Gitea...${NC}"
git add .
# Verifica se existem alterações para o commit
if git diff-index --quiet HEAD --; then
echo -e "${GREEN}✨ Código local já está sincronizado com o último commit.${NC}"
else
TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S")
echo -e "${CYAN}📤 Enviando alterações (Auto-update $TIMESTAMP)...${NC}"
git commit -m "🚀 Auto-deploy: BrainKP atualizado em $TIMESTAMP"
git push origin master
fi
# 2. Gatilho de Deploy no Coolify (API Oficial v1)
if [ ! -z "$COOLIFY_RESOURCE_UUID" ]; then
echo -e "${YELLOW}🔄 Disparando Deploy via API Oficial no Coolify...${NC}"
curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${COOLIFY_RESOURCE_UUID}&force=false" \
-H "Authorization: Bearer $COOLIFY_TOKEN"
echo -e "\n${GREEN}✅ Deploy oficial via API engatilhado.${NC}"
fi
# 3. Resultado Final
echo -e "${GREEN}🏁 Ciclo concluído com sucesso.${NC}\n"