Compare commits

..

7 Commits

3 changed files with 154 additions and 11 deletions
+99 -1
View File
@@ -221,6 +221,97 @@ def status():
stats = get_backup_stats()
return jsonify({"log": log, "stats": stats})
# ──────────────────────────────────────────────
# CLEANUP
# ──────────────────────────────────────────────
@app.route("/api/cleanup/<backup_type>", methods=["POST"])
def cleanup_backups(backup_type):
remotes = {
"apps": "gdrive:Apps",
"bd": "gdrive:BD",
"git": "gdrive:Git",
"repos": "gdrive:Git/repos"
}
remote = remotes.get(backup_type)
if not remote:
return jsonify({"error": "Unknown type"}), 400
if backup_type == "repos":
items = list_rclone_contents("gdrive", "Git/repos", depth=2)
elif backup_type == "git":
items = list_rclone_contents("gdrive", "Git")
items = [i for i in items if "gitea-data" in i.get("Name", "")]
else:
items = list_rclone_contents("gdrive", remote.split(":")[1])
import re
def get_group_key(item):
path = item.get("Path", item.get("Name", ""))
# For repos, path might be owner/repo-datetime.tar.gz
# Extract base name before the date string (e.g. -20260724_021744)
match = re.search(r'(-\d{8}_\d{6})', path)
if match:
return path[:match.start()]
return path.split('-')[0]
# Filter out directories
items = [i for i in items if not i.get("IsDir")]
# Group by key
grouped = {}
for item in items:
key = get_group_key(item)
if key not in grouped:
grouped[key] = []
grouped[key].append(item)
import tempfile
import os
to_delete_paths = []
for key, group_items in grouped.items():
# Sort by ModTime descending (newest first)
group_items.sort(key=lambda x: x.get("ModTime", ""), reverse=True)
# Keep first 2
to_delete = group_items[2:]
for item in to_delete:
to_delete_paths.append(item.get("Path", item.get("Name")))
deleted_count = 0
errors = []
if to_delete_paths:
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
for p in to_delete_paths:
f.write(p + '\n')
list_file = f.name
cmd = [
"rclone", "delete", remote,
"--files-from", list_file,
"--drive-root-folder-id", "1ey-5aABgHDtirxeJarWy0ZmeqQPVTYtm",
"--drive-use-trash=false" # permanently delete to immediately free space
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
os.remove(list_file)
if result.returncode == 0:
deleted_count = len(to_delete_paths)
else:
errors.append(f"Failed to bulk delete: {result.stderr}")
# invalidate cache
global _stats_cache
_stats_cache["timestamp"] = 0
return jsonify({
"ok": True,
"deleted_count": deleted_count,
"errors": errors
})
# ──────────────────────────────────────────────
# LIST BACKUPS
# ──────────────────────────────────────────────
@@ -243,7 +334,7 @@ def list_backups(backup_type):
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())
items = list_rclone_contents("gdrive", remote.split(":")[1])
# Group repos by owner
if backup_type == "repos":
@@ -458,6 +549,13 @@ def run_backup():
except Exception as e:
return jsonify({"error": str(e), "trace": traceback.format_exc()}), 500
@app.route("/api/download/<filename>")
def download_file(filename):
path = f"/tmp/restore-temp/{filename}"
if os.path.exists(path):
return send_file(path, as_attachment=True)
return "File not found locally", 404
# ──────────────────────────────────────────────
# STATIC & LOGS
# ──────────────────────────────────────────────
+54 -9
View File
@@ -55,8 +55,8 @@ input,button,select{font-family:inherit}
.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}
.card .run-btn{display:block;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{opacity:0.9}
/* 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}
@@ -221,6 +221,7 @@ input,button,select{font-family:inherit}
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer" id="modal-footer">
<a id="modal-download" class="btn" style="display:none; text-decoration:none;" target="_blank">📥 Baixar</a>
<button class="btn" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary" id="modal-confirm" onclick="doRestore()">Restaurar</button>
</div>
@@ -319,7 +320,8 @@ async function loadStatus() {
</div>
${c.running
? `<div style="margin-top:12px;text-align:center;font-size:11px;color:var(--accent);font-weight:bold"><span class="loading"></span> Sincronizando...</div>`
: `<button class="run-btn" onclick="event.stopPropagation();runBackup('${c.id}')">▶ Executar Backup</button>`
: `<button class="run-btn" onclick="event.stopPropagation();runBackup('${c.id}')">▶ Executar Backup</button>
<button class="run-btn" style="margin-top:5px; background:var(--danger)" onclick="event.stopPropagation();cleanupBackups('${c.id}')">🗑️ Limpar Antigos</button>`
}
</div>
`).join('');
@@ -358,18 +360,21 @@ function renderBackupList(items) {
list.innerHTML = '<div class="empty"><div class="empty-icon">📭</div>Nenhum backup encontrado</div>';
return;
}
list.innerHTML = items.map(item => `
list.innerHTML = items.map(item => {
const appName = getAppBaseName(item.name);
return `
<div class="backup-item">
<div class="bi-left">
<span class="bi-name">${item.name}</span>
<span class="bi-date">${formatDateTime(item.modified)}</span>
<div class="bi-left" style="flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0;">
<span class="badge yellow" style="min-width: 100px; max-width: 120px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${appName}">${appName}</span>
<span class="bi-name" style="flex: 1; min-width: 0;">${item.name}</span>
<span class="bi-date" style="white-space: nowrap; margin-right: 10px;">${formatDateTime(item.modified)}</span>
</div>
<div class="bi-right">
<span class="bi-size">${formatSize(item.size)}</span>
<span class="bi-size" style="min-width: 60px; text-align: right;">${formatSize(item.size)}</span>
<button class="restore-btn" onclick="openRestoreModal('${currentTab}', '${item.name}', '${item.size}')">Restaurar</button>
</div>
</div>
`).join('');
`}).join('');
}
function renderRepos(grouped) {
@@ -448,6 +453,33 @@ async function runBackup(type) {
btn.innerHTML = '▶ Executar Backup';
}
async function cleanupBackups(type) {
if (!confirm(`Tem certeza que deseja apagar backups antigos de ${getTypeLabel(type)} (mantendo apenas os 2 mais recentes de cada)?`)) return;
const btn = event.target;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span> Limpando...';
try {
const res = await fetch(`${API}/cleanup/${type}`, { method: 'POST' });
const d = await res.json();
if (d.ok) {
if (d.errors && d.errors.length > 0) {
showToast(`Concluído com alertas: ${d.deleted_count} apagados. ${d.errors.length} erros.`, 'warning');
} else {
showToast(`Limpeza concluída! ${d.deleted_count} backups antigos apagados.`, 'ok');
}
loadStatus();
loadBackups();
} else {
showToast(d.error || 'Erro na limpeza', 'error');
}
} catch(e) {
showToast('Erro ao realizar limpeza', 'error');
}
btn.disabled = false;
btn.innerHTML = originalText;
}
let restoreTarget = null;
function openRestoreModal(type, filename, size) {
@@ -473,6 +505,7 @@ function openRestoreModal(type, filename, size) {
<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('modal-download').style.display = 'none';
document.getElementById('restore-modal').classList.add('show');
// Start download
@@ -511,6 +544,8 @@ function openRestoreModal(type, filename, size) {
` cp -r ${d.extracted_to}/* /var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/`;
}
document.getElementById('restore-instructions').value = instructions || 'Pronto para uso.';
document.getElementById('modal-download').href = `/api/download/${filename}`;
document.getElementById('modal-download').style.display = 'inline-block';
} else {
document.getElementById('restore-instructions').value = `Erro: ${d.error}`;
}
@@ -538,6 +573,7 @@ function openRestoreRepo(owner, repoName, size) {
<textarea id="restore-instructions" disabled>Baixando e preparando...</textarea>
</div>
`;
document.getElementById('modal-download').style.display = 'none';
document.getElementById('restore-modal').classList.add('show');
fetch(`${API}/restore/repo`, {
@@ -557,6 +593,9 @@ function openRestoreRepo(owner, repoName, size) {
` 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>`;
document.getElementById('modal-download').href = `/api/download/${repoName}`;
document.getElementById('modal-download').style.display = 'inline-block';
} else {
document.getElementById('restore-instructions').value = `Erro: ${d.error}`;
}
@@ -631,6 +670,12 @@ function getTypeLabel(type) {
return {apps:'📦 Apps', bd:'🗄️ Banco de Dados', git:'🐙 Git Full', repos:'📁 Repositórios'}[type] || type;
}
function getAppBaseName(filename) {
const match = filename.match(/(.*?)(?:-\d{8}_\d{6})/);
if (match) return match[1];
return filename.split('-')[0];
}
// Auto-refresh every 30s
setInterval(() => {
if (document.getElementById('app').style.display !== 'none') {
+1 -1
View File
@@ -32,7 +32,7 @@ fi
# 2. Gatilho de Deploy no Coolify (API Oficial v1)
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}"
-H "Authorization: Bearer ${COOLIFY_TOKEN}" \
-H "Content-Type: application/json"
echo -e "\n${GREEN}✅ Deploy oficial via API engatilhado.${NC}"