Files
BrainKP/static/index.html
T

634 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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');
let isAnyRunning = false;
let runningScripts = [];
if (lr) {
const isRunning = lr.scripts && lr.scripts.some(s => s.status === 'running');
if (isRunning) {
isAnyRunning = true;
runningScripts = lr.scripts ? lr.scripts.filter(s => s.status === 'running').map(s => s.name) : [];
badge.innerHTML = '<span class="loading" style="width:10px;height:10px;margin-right:4px;border-width:2px"></span> Em andamento';
badge.className = 'badge yellow';
} else {
const ok = !lr.has_error && lr.scripts && lr.scripts.every(s => s.status === 'ok');
badge.textContent = ok ? '✓ Concluído' : '⚠ Erro';
badge.className = `badge ${ok ? 'green' : 'red'}`;
}
} else {
badge.textContent = '—';
badge.className = 'badge yellow';
}
// Cards
const s = d.stats;
const isAppsRunning = runningScripts.includes('Backup Apps');
const isBdRunning = runningScripts.includes('Backup BD');
const isGitRunning = runningScripts.includes('Backup Git');
const isReposRunning = runningScripts.includes('Backup Git repos');
const cards = [
{id:'apps', icon:'📦', name:'Apps', count: s.apps.count, meta: s.apps.latest ? `Último: ${formatDateTime(s.apps.latest)}` : 'Nenhum backup', size: formatSize(s.apps.size), running: isAppsRunning},
{id:'bd', icon:'🗄️', name:'Bancos', count: s.bd.count, meta: s.bd.latest ? `Último: ${formatDateTime(s.bd.latest)}` : 'Nenhum backup', size: formatSize(s.bd.size), running: isBdRunning},
{id:'git', icon:'🐙', name:'Git Full', count: s.git.count, meta: s.git.latest ? `Último: ${formatDateTime(s.git.latest)}` : 'Nenhum backup', size: formatSize(s.git.size), running: isGitRunning},
{id:'repos', icon:'📁', name:'Repos', count: s.repos.count, meta: s.repos.latest ? `Último: ${formatDateTime(s.repos.latest)}` : 'Nenhum backup', size: formatSize(s.repos.size), running: isReposRunning},
];
document.getElementById('cards').innerHTML = cards.map(c => `
<div class="card" onclick="switchTab('${c.id}')" style="${c.running ? 'border-color:var(--accent)' : ''}">
<div class="card-icon">${c.icon}</div>
<div class="card-name">${c.name}</div>
<div class="card-meta">
<span style="font-weight:600;color:var(--text);font-size:12px;margin:4px 0">${c.count} arquivo${c.count!==1?'s':''} de backup</span>
<span>${c.meta}</span>
<span>Tamanho: ${c.size}</span>
</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>`
}
</div>
`).join('');
// Poll faster if something is running
if (isAnyRunning) {
if (!window.pollIntervalFast) {
window.pollIntervalFast = setInterval(loadStatus, 2000);
}
} else {
if (window.pollIntervalFast) {
clearInterval(window.pollIntervalFast);
window.pollIntervalFast = null;
// Reload backups to show the newly finished one
loadBackups();
}
}
} 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">${formatDateTime(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 ✓`);
loadStatus();
} 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 formatDateTime(dateStr) {
if (!dateStr) return '—';
try {
// try to match YYYYMMDD_HHMMSS if it's from filename
const m = dateStr.match(/(\d{4})(\d{2})(\d{2})_?(\d{2})?(\d{2})?(\d{2})?/);
let d;
if (m && m[1] && m[2] && m[3] && dateStr.indexOf('-') === -1) {
d = new Date(m[1], m[2]-1, m[3], m[4]||0, m[5]||0, m[6]||0);
} else {
d = new Date(dateStr);
}
if (isNaN(d.getTime())) return dateStr;
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
const hr = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} às ${hr}:${min}`;
} 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>