const extraState = { app: false, all: false }; let selectedUser = null; let manualBackupJobId = null; let manualBackupPoll = null; let currentJobId = null; let pollInterval = null; let allContainersCache = []; let allContainersStats = {}; 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[data-ctr="${name}"] .ctr-status-cell`).forEach((cell) => { cell.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 `
`; } function parseCpuPct(cpuStr) { if (!cpuStr || cpuStr === '—') return 0; return parseFloat(String(cpuStr).replace('%', '')) || 0; } function parseCreatedTs(createdAt) { if (!createdAt) return 0; const t = Date.parse(createdAt); return Number.isNaN(t) ? 0 : t; } function parseUptimeSeconds(uptimeText) { if (!uptimeText || !uptimeText.startsWith('Up')) return 0; const text = uptimeText.replace(/^Up\s+/i, '').toLowerCase(); let sec = 0; const dayM = text.match(/(\d+)\s*day/); const hrM = text.match(/(\d+)\s*hour/); const minM = text.match(/(\d+)\s*minute/); const secM = text.match(/(\d+)\s*second/); if (dayM) sec += parseInt(dayM[1], 10) * 86400; if (hrM) sec += parseInt(hrM[1], 10) * 3600; if (minM) sec += parseInt(minM[1], 10) * 60; if (secM) sec += parseInt(secM[1], 10); return sec; } function filterAndSortContainers(containers, stats, query, sortBy) { let list = [...containers]; const q = (query || '').trim().toLowerCase(); if (q) { list = list.filter((c) => (c.name || '').toLowerCase().includes(q) || (c.image || '').toLowerCase().includes(q) || (c.owner || '').toLowerCase().includes(q) || (c.ports || '').toLowerCase().includes(q) || (c.uptime || '').toLowerCase().includes(q) ); } list.sort((a, b) => { switch (sortBy) { case 'cpu_desc': return parseCpuPct((stats[b.name] || {}).cpu) - parseCpuPct((stats[a.name] || {}).cpu); case 'cpu_asc': return parseCpuPct((stats[a.name] || {}).cpu) - parseCpuPct((stats[b.name] || {}).cpu); case 'created_desc': return parseCreatedTs(b.created_at) - parseCreatedTs(a.created_at); case 'created_asc': return parseCreatedTs(a.created_at) - parseCreatedTs(b.created_at); case 'uptime_desc': return parseUptimeSeconds(b.uptime) - parseUptimeSeconds(a.uptime); case 'name': default: return (a.name || '').localeCompare(b.name || ''); } }); return list; } function renderAllContainersRows(containers, stats) { const body = el('all-containers-body'); if (!body) return; const filterMeta = el('ctr-filter-meta'); const total = allContainersCache.length; if (filterMeta) { filterMeta.textContent = containers.length === total ? `${total} shown` : `${containers.length} of ${total} shown`; } if (!containers.length) { body.innerHTML = `
${total ? 'No matches' : 'No containers'}
`; return; } const showExtra = extraState.all; const ed = showExtra ? '' : 'display:none;'; body.innerHTML = containers.map((c) => { const up = c.running || (c.status && 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 uptimeCls = up ? 'ct-uptime' : 'ct-uptime stopped'; const uptimeText = c.uptime || (up ? c.status : c.status || '—'); return ` ${escapeHtml(c.name)} ${escapeHtml(c.owner || 'root')} ${up ? 'Running' : 'Stopped'} ${escapeHtml(uptimeText)} ${escapeHtml(s.cpu || '—')}
${escapeHtml(s.mem_pct || '—')}
${escapeHtml(s.net || '—')} ${escapeHtml(s.block || '—')} ${escapeHtml(c.image)} ${escapeHtml(c.ports || '—')} ${buildActionBtns(c.name)}`; }).join(''); } function applyContainersFilter() { const query = el('ctr-search')?.value || ''; const sortBy = el('ctr-sort')?.value || 'name'; const filtered = filterAndSortContainers(allContainersCache, allContainersStats, query, sortBy); renderAllContainersRows(filtered, allContainersStats); } 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(); allContainersCache = containers || []; allContainersStats = await statRes.json(); if (meta) meta.textContent = `${allContainersCache.length} total · ${running} running`; if (el('nav-badge-containers')) el('nav-badge-containers').textContent = allContainersCache.length; applyContainersFilter(); } catch (e) { body.innerHTML = `
${escapeHtml(String(e))}
`; } } window.applyContainersFilter = applyContainersFilter; 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 jsStr(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } 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) => { const safe = jsStr(b); const uploadBtn = source === 'local' && typeof quickUploadR2 === 'function' ? `` : ''; const detailsBtn = typeof showBackupDetails === 'function' ? `` : ''; return `
${escapeHtml(b)}
${detailsBtn} ${uploadBtn}
`; }).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) => `
${escapeHtml(u.name[0].toUpperCase())}
${escapeHtml(u.name)}
uid ${u.uid}
${u.has_docker ? ' docker' : ''}${u.linger ? 'linger' : ''}${u.has_vdisk ? ' vdisk' : ''}
Disk${escapeHtml(u.disk_used)}
Containers${u.container_count}
`).join(''); } catch (_) {} } async function loadUserContainers(username) { selectedUser = username; document.querySelectorAll('.user-card').forEach((card) => { card.classList.toggle('selected', card.dataset.user === username); }); if (el('user-detail-panel')) { el('user-detail-panel').style.display = ''; el('user-detail-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } 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'); const extras = []; if (typeof window.loadSites === 'function') extras.push(window.loadSites(true)); Promise.all([ checkServerStatus(), refreshSystemMetrics(), refreshContainerStats(), refreshSidebarNavBadges(), ...extras, ]).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); });