Files
BrainKP/static/index.html
T

601 lines
24 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');
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>