Initial commit: BrainKP Backup UI

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