Initial commit: BrainKP Backup UI
This commit is contained in:
@@ -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"]
|
||||||
@@ -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)
|
||||||
Executable
+28
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
gunicorn>=21.0.0
|
||||||
@@ -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 `há ${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 `há ${mins}m`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `há ${hrs}h`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
if (days < 7) return `há ${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>
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user