const extraState = { app: false, all: false }; let selectedUser = null; let manualBackupJobId = null; let manualBackupPoll = null; let currentJobId = null; let pollInterval = null; function el(id) { return document.getElementById(id); } function escapeHtml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function auditScoreBlockTierClass(tier) { const t = String(tier || 'fair'); if (t === 'excellent' || t === 'good') return 'audit-tier-good'; if (t === 'fair') return 'audit-tier-fair'; if (t === 'poor') return 'audit-tier-poor'; return 'audit-tier-critical'; } function renderAuditModalContent(d, filename) { if (d.error) { return `
${escapeHtml(d.error)}
`; } const tierCls = auditScoreBlockTierClass(d.health_tier); const score = Math.max(0, Math.min(100, Number(d.score) || 0)); const fileSize = d.file_size_display ? escapeHtml(d.file_size_display) : (d.file_size_bytes != null ? escapeHtml(String(d.file_size_bytes)) + ' B' : '—'); const fname = escapeHtml(d.backup_file || filename); const summary = escapeHtml(d.summary || ''); const healthLabel = escapeHtml(d.health_label || ''); const badgeSafe = !!d.ok; const badgeText = badgeSafe ? '✓ Safe to restore' : '✗ Review before restore'; const badgeClass = badgeSafe ? 'audit-health-badge audit-health-badge-safe' : 'audit-health-badge audit-health-badge-unsafe'; const checksHtml = (d.checks || []).map((c) => { const st = c.status === 'fail' ? 'fail' : c.status === 'warn' ? 'warn' : 'pass'; const icon = st === 'pass' ? '✓' : st === 'warn' ? '⚠' : '✗'; const iconCls = `audit-check-icon audit-check-icon-${st}`; const detail = escapeHtml(c.detail || ''); const moreLines = Array.isArray(c.more) ? c.more : []; const moreBlock = moreLines.length ? `` : ''; return `
${icon} ${escapeHtml(c.name)} ${st}

${detail}

${moreBlock}
`; }).join(''); return `
File
${fname}
Size${fileSize}
${score}
Score
${healthLabel} ${badgeText}

${summary}

Expand a row to see exactly what was verified (size, checksum, archive test, paths, …).

${checksHtml}
`; } function quickRestoreFromAuditModal() { const modal = el('audit-modal'); if (!modal) return; const s = modal.dataset.auditSource || ''; const f = modal.dataset.auditFile || ''; modal.style.display = 'none'; if (s && f) quickRestore(s, f); } function setText(id, value) { const node = el(id); if (node) node.textContent = value; } function toggleExtraColumns(prefix) { extraState[prefix] = !extraState[prefix]; const show = extraState[prefix]; document.querySelectorAll(`.${prefix}-extra`).forEach((node) => { node.style.display = show ? "" : "none"; }); const btn = el(`${prefix}-toggle-btn`); if (btn) { btn.innerHTML = show ? ' Show less' : ' Show more'; } } /** Sidebar counts (present in base.html on every page). */ async function refreshSidebarNavBadges() { const setBadges = (containerCount, userCount) => { const c = el('nav-badge-containers'); const u = el('nav-badge-users'); if (c) c.textContent = containerCount != null ? containerCount : '—'; if (u) u.textContent = userCount != null ? userCount : '—'; }; try { const r = await fetch('/api/nav-summary', { credentials: 'same-origin' }); const d = r.ok ? await r.json() : null; const cc = d && Number.isFinite(Number(d.container_count)) ? Number(d.container_count) : null; const uc = d && Number.isFinite(Number(d.user_count)) ? Number(d.user_count) : null; setBadges(cc, uc); } catch (_) { setBadges(null, null); } } async function checkServerStatus() { try { const r = await fetch('/server/status'); const d = await r.json(); const dot = el('pulse-dot'); const text = el('server-status-text'); if (!dot || !text) return; if (d.status === 'online') { dot.className = 'pulse-dot online'; text.textContent = 'Online'; } else { dot.className = 'pulse-dot offline'; text.textContent = 'Offline'; } } catch (_) {} } async function refreshSystemMetrics() { try { const r = await fetch('/api/system'); const d = await r.json(); if (el('m-cpu')) el('m-cpu').innerHTML = d.cpu_pct + '%'; setText('m-mem', d.memory); setText('m-disk', d.disk); setText('m-load', d.load); if (el('g-cpu')) el('g-cpu').style.width = Math.min(parseFloat(d.cpu_pct) || 0, 100) + '%'; if (el('g-mem')) el('g-mem').style.width = Math.min(parseFloat(d.mem_pct) || 0, 100) + '%'; if (el('g-disk')) el('g-disk').style.width = Math.min(parseFloat(d.disk_pct) || 0, 100) + '%'; setText('uptime-chip', d.uptime); if (el('settings-uptime')) el('settings-uptime').value = d.uptime; if (d.hostname && el('this-server-desc')) el('this-server-desc').textContent = d.hostname; } catch (_) {} } async function refreshContainerStats() { try { const r = await fetch('/api/stats'); const stats = await r.json(); document.querySelectorAll('[data-stat]').forEach((node) => { const name = node.dataset.ctr; const stat = node.dataset.stat; const s = stats[name]; if (!s) return; if (stat === 'cpu') node.textContent = s.cpu || '—'; if (stat === 'net') node.textContent = s.net || '—'; if (stat === 'block') node.textContent = s.block || '—'; if (stat === 'mem_pct') node.textContent = s.mem_pct || '—'; if (stat === 'mem_bar') { const pct = parseFloat(s.mem_pct) || 0; node.style.width = Math.min(pct, 100) + '%'; node.className = 'stat-bar-fill' + (pct > 85 ? ' crit' : pct > 65 ? ' warn' : ''); } }); } catch (_) {} } function statusBadgeHTML(status) { if (status === 'running') return 'Running'; if (status === 'stopped') return 'Stopped'; return 'Unknown'; } function updateContainerStatusBadge(name, status) { document.querySelectorAll(`.ctr-status-cell[data-ctr="${name}"]`).forEach((cell) => { cell.innerHTML = statusBadgeHTML(status); }); document.querySelectorAll('#all-containers-body tr').forEach((row) => { const nameTd = row.querySelector('.ct-name'); if (nameTd && nameTd.textContent.trim() === name) { const statusTd = row.cells[2]; if (statusTd) statusTd.innerHTML = statusBadgeHTML(status); } }); } async function ctrAction(name, action, btn) { const origHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; try { const r = await fetch('/api/container/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, action }) }); const d = await r.json(); if (d.new_status) updateContainerStatusBadge(name, d.new_status); btn.innerHTML = d.success ? '' : ''; setTimeout(() => { btn.innerHTML = origHTML; btn.disabled = false; setTimeout(() => { refreshContainerStats(); if (el('all-containers-body')) loadAllContainers(); }, 1500); }, 1200); } catch (_) { btn.innerHTML = origHTML; btn.disabled = false; } } function buildActionBtns(name) { return `
`; } async function loadAllContainers() { const body = el('all-containers-body'); if (!body) return; const meta = el('all-ctr-meta'); body.innerHTML = '
Loading…
'; try { const [ctrRes, statRes] = await Promise.all([fetch('/api/containers/all'), fetch('/api/stats')]); const { containers, running } = await ctrRes.json(); const stats = await statRes.json(); if (meta) meta.textContent = `${containers.length} total · ${running} running`; if (el('nav-badge-containers')) el('nav-badge-containers').textContent = containers.length; if (!containers.length) { body.innerHTML = '
No containers
'; return; } const showExtra = extraState.all; body.innerHTML = containers.map((c) => { const up = c.status.includes('Up'); const s = stats[c.name] || {}; const pct = parseFloat(s.mem_pct) || 0; const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : ''; const ed = showExtra ? '' : 'display:none;'; return ` ${c.name}${c.owner} ${up ? 'Running' : 'Stopped'} ${s.cpu || '—'}
${s.mem_pct || '—'}
${s.net || '—'} ${s.block || '—'} ${c.image} ${c.ports || '—'} ${buildActionBtns(c.name)}`; }).join(''); } catch (e) { body.innerHTML = `
${e}
`; } } async function refreshBackupsList() { try { const r = await fetch('/api/backups'); const d = await r.json(); renderBackupList(d.local, 'local-backup-list', 'local'); renderBackupList(d.vm, 'vm-backup-list', 'vm'); if (el('stat-local-bk')) el('stat-local-bk').textContent = d.local.length; if (el('stat-vm-bk')) el('stat-vm-bk').textContent = d.vm.length; if (el('local-options')) el('local-options').innerHTML = d.local.length ? d.local.map((b) => ``).join('') : ''; if (el('vm-options')) el('vm-options').innerHTML = d.vm.length ? d.vm.map((b) => ``).join('') : ''; } catch (_) {} } function renderBackupList(items, id, source) { const node = el(id); if (!node) return; if (!items || !items.length) { node.innerHTML = '
No backups
'; return; } node.innerHTML = items.map((b) => `
${b}
`).join(''); } async function auditBackup(source, filename, btn) { const modal = el('audit-modal'); const content = el('audit-modal-content'); if (!modal || !content) return; modal.dataset.auditSource = source; modal.dataset.auditFile = filename; modal.style.display = 'flex'; content.innerHTML = '
Running audit…
'; if (btn) { btn.disabled = true; btn.innerHTML = ''; } try { const r = await fetch('/api/backups/audit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backup_file: filename, source }) }); const d = await r.json(); if (!r.ok) { content.innerHTML = renderAuditModalContent({ error: d.error || d.message || `HTTP ${r.status}` }, filename); return; } content.innerHTML = renderAuditModalContent(d, filename); } catch (e) { content.innerHTML = `
Audit failed: ${escapeHtml(e)}
`; } finally { if (btn) { btn.disabled = false; btn.innerHTML = ' Audit'; } } } function closeAuditModal(e) { const modal = el('audit-modal'); if (!modal) return; if (!e || e.target === modal) modal.style.display = 'none'; } async function deleteBackup(source, filename, btn) { if (!confirm(`Delete backup:\n${filename}\n\nThis cannot be undone.`)) return; if (btn) { btn.disabled = true; btn.innerHTML = ''; } try { const r = await fetch('/api/backups/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backup_file: filename, source }) }); const d = await r.json(); if (!d.success) alert(`Delete failed: ${d.message}`); refreshBackupsList(); } catch (e) { alert(`Error: ${e}`); } finally { if (btn) { btn.disabled = false; btn.innerHTML = ''; } } } async function runManualBackup() { const btn = el('manual-backup-btn'); if (!btn) return; btn.disabled = true; btn.innerHTML = ' Starting…'; const wrapper = el('manual-backup-wrapper'); const logEl = el('manual-backup-log'); if (wrapper) wrapper.style.display = ''; if (logEl) logEl.innerHTML = ''; try { const r = await fetch('/api/backups/run', { method: 'POST' }); const d = await r.json(); if (!d.success) throw new Error(d.message || 'Failed'); manualBackupJobId = d.job_id; pollManualBackup(); } catch (e) { if (logEl) logEl.textContent = `❌ ${e}`; btn.disabled = false; btn.innerHTML = ' Run Backup Now'; } } function pollManualBackup() { if (manualBackupPoll) clearInterval(manualBackupPoll); let lastLine = 0; manualBackupPoll = setInterval(async () => { if (!manualBackupJobId) return; try { const r = await fetch(`/api/backups/run/status/${manualBackupJobId}`); const d = await r.json(); d.log.slice(lastLine).forEach(appendBackupLog); lastLine = d.log.length; if (el('manual-backup-elapsed')) el('manual-backup-elapsed').textContent = `⏱ ${d.elapsed}s`; if (d.status !== 'running') { clearInterval(manualBackupPoll); if (el('manual-backup-btn')) { el('manual-backup-btn').disabled = false; el('manual-backup-btn').innerHTML = ' Run Backup Now'; } if (d.status === 'done') setTimeout(refreshBackupsList, 1200); } } catch (_) {} }, 1500); } function appendBackupLog(line) { const logEl = el('manual-backup-log'); if (!logEl) return; const div = document.createElement('div'); div.className = 'log-line'; div.textContent = line; logEl.appendChild(div); logEl.scrollTop = logEl.scrollHeight; } async function loadBackupLog() { const node = el('backup-history-list'); if (!node) return; node.innerHTML = '
Loading…
'; try { const r = await fetch('/api/backups/log?limit=15'); const d = await r.json(); if (!d.entries || !d.entries.length) { node.innerHTML = '
No backup history yet
'; return; } node.innerHTML = d.entries.map((e) => `
${e.name}${e.status}
`).join(''); } catch (e) { node.innerHTML = `
Error loading log: ${e}
`; } } const userColors = ['#3b82f6', '#a78bfa', '#22c55e', '#f59e0b', '#ef4444', '#22d3ee']; async function loadUsers() { const grid = el('users-grid'); if (!grid) return; try { const r = await fetch('/api/users'); const users = await r.json(); if (el('nav-badge-users')) el('nav-badge-users').textContent = users.length; if (el('stat-users')) el('stat-users').textContent = users.length; if (!users.length) { grid.innerHTML = '
No users
'; return; } grid.innerHTML = users.map((u, i) => `
${u.name[0].toUpperCase()}
${u.name}
uid ${u.uid}
${u.has_docker ? ' docker' : ''}${u.linger ? 'linger' : ''}${u.has_vdisk ? '💾 vdisk' : ''}
Disk: ${u.disk_used}
Ctrs: ${u.container_count}
`).join(''); } catch (_) {} } async function loadUserContainers(username) { selectedUser = username; if (el('user-detail-panel')) el('user-detail-panel').style.display = ''; setText('user-detail-title', `${username}'s Containers`); const body = el('user-containers-body'); if (!body) return; body.innerHTML = '
'; try { const r = await fetch(`/api/users/${username}/containers`); const ctrs = await r.json(); if (!ctrs.length) { body.innerHTML = '
No containers
'; return; } body.innerHTML = ctrs.map((c) => `${c.name}${c.status.includes('Up') ? 'Running' : 'Stopped'}${c.image}${c.ports || '—'}`).join(''); } catch (_) {} } function showAlert(id, type, msg) { const node = el(id); if (!node) return; node.className = `alert alert-${type} show`; node.textContent = msg; } async function createUser() { const username = el('new-username')?.value.trim(); const password = el('new-password')?.value.trim(); const quota = el('new-quota')?.value.trim(); const docker = !!el('new-docker')?.checked; if (!username) return showAlert('create-user-result', 'error', 'Username required'); const btn = event.target; btn.disabled = true; try { const r = await fetch('/api/users/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: password || null, setup_docker: docker, disk_quota_mb: quota ? parseInt(quota, 10) : null }) }); const d = await r.json(); showAlert('create-user-result', d.success ? 'success' : 'error', d.message || ''); if (d.success) loadUsers(); } catch (e) { showAlert('create-user-result', 'error', `${e}`); } finally { btn.disabled = false; } } async function deleteUser() { if (!selectedUser || !confirm(`Delete user "${selectedUser}"?`)) return; const removeHome = confirm(`Also delete /home/${selectedUser}?`); try { const r = await fetch('/api/users/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: selectedUser, remove_home: removeHome }) }); const d = await r.json(); showAlert('user-action-result', d.success ? 'success' : 'error', d.message || ''); if (d.success) { if (el('user-detail-panel')) el('user-detail-panel').style.display = 'none'; loadUsers(); } } catch (e) { showAlert('user-action-result', 'error', `${e}`); } } function updateBackupList() { const src = document.querySelector('input[name="backup_source"]:checked')?.value; if (!src) return; if (el('local-options')) el('local-options').style.display = src === 'local' ? '' : 'none'; if (el('vm-options')) el('vm-options').style.display = src === 'vm' ? '' : 'none'; } function toggleRemoteFields() { const v = document.querySelector('input[name="restore_target"]:checked')?.value; if (el('remote-fields')) el('remote-fields').style.display = v === 'remote' ? '' : 'none'; } function toggleAuthFields() { const m = document.querySelector('input[name="auth_method"]:checked')?.value; if (el('key-field')) el('key-field').style.display = m === 'key' ? '' : 'none'; if (el('password-field')) el('password-field').style.display = m === 'password' ? '' : 'none'; } function quickRestore(source, filename) { window.location.href = `/restore?source=${encodeURIComponent(source)}&file=${encodeURIComponent(filename)}`; } function appendLog(line) { const logEl = el('restore-log'); if (!logEl) return; const div = document.createElement('div'); div.className = 'log-line'; div.textContent = line; logEl.appendChild(div); logEl.scrollTop = logEl.scrollHeight; } async function launchRestore() { const src = document.querySelector('input[name="backup_source"]:checked')?.value; const file = el('backup-file-select')?.value; const target = document.querySelector('input[name="restore_target"]:checked')?.value; if (!file) return alert('Select a backup file.'); const payload = { backup_source: src, backup_file: file, target }; if (target === 'remote') { payload.remote_ip = el('remote-ip')?.value.trim(); payload.remote_port = el('remote-port')?.value.trim() || '22'; payload.remote_user = el('remote-user')?.value.trim() || 'root'; payload.auth_method = document.querySelector('input[name="auth_method"]:checked')?.value; if (payload.auth_method === 'key') payload.ssh_key_path = el('ssh-key-path')?.value.trim(); else payload.ssh_password = el('ssh-password')?.value || ''; if (!payload.remote_ip) return alert('Enter target IP.'); } if (!confirm(`Restore "${file}" now?`)) return; if (el('restore-log-wrapper')) el('restore-log-wrapper').style.display = ''; if (el('restore-log')) el('restore-log').innerHTML = ''; try { const res = await fetch('/restore/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json(); if (data.error) return appendLog(`❌ ${data.error}`); currentJobId = data.job_id; pollRestore(); } catch (e) { appendLog(`❌ ${e}`); } } function pollRestore() { if (pollInterval) clearInterval(pollInterval); let lastLine = 0; pollInterval = setInterval(async () => { if (!currentJobId) return; try { const r = await fetch(`/restore/status/${currentJobId}`); const d = await r.json(); d.log.slice(lastLine).forEach(appendLog); lastLine = d.log.length; if (el('restore-elapsed')) el('restore-elapsed').textContent = `⏱ ${d.elapsed}s`; if (d.status !== 'running') clearInterval(pollInterval); } catch (_) {} }, 1500); } function refreshAll() { const btn = document.querySelector('.icon-btn'); if (btn) btn.classList.add('spinning'); Promise.all([checkServerStatus(), refreshSystemMetrics(), refreshContainerStats(), refreshSidebarNavBadges()]) .finally(() => { if (btn) btn.classList.remove('spinning'); }); } document.addEventListener('DOMContentLoaded', () => { checkServerStatus(); refreshSystemMetrics(); refreshContainerStats(); refreshSidebarNavBadges(); if (el('all-containers-body')) loadAllContainers(); if (el('backup-history-list')) { refreshBackupsList(); loadBackupLog(); } if (el('users-grid')) loadUsers(); if (window.restorePrefill && el('backup-file-select')) { const { source, file } = window.restorePrefill; if (source) { const sourceRadio = document.querySelector(`input[name="backup_source"][value="${source}"]`); if (sourceRadio) sourceRadio.checked = true; updateBackupList(); } if (file) { const sel = el('backup-file-select'); for (const opt of sel.options) { if (opt.value === file) { opt.selected = true; break; } } } } setInterval(() => { refreshSystemMetrics(); refreshContainerStats(); refreshSidebarNavBadges(); }, 15000); setInterval(checkServerStatus, 30000); });