/* ── The Sentinel – Backup Monitor Frontend ────────────────── */ const API = ''; let apiKey = localStorage.getItem('bm_api_key') || ''; let allHosts = []; let currentPage = 'dashboard'; // ── Init ────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { loadAll(); setInterval(loadAll, 30000); }); function authHeaders() { const h = {'Content-Type': 'application/json'}; if (apiKey) h['X-API-Key'] = apiKey; return h; } async function apiFetch(url, opts = {}) { if (!opts.headers) opts.headers = {}; if (apiKey) opts.headers['X-API-Key'] = apiKey; const r = await fetch(url, opts); if (r.status === 401) { const key = prompt('🔑 API-Key eingeben:'); if (key) { apiKey = key; localStorage.setItem('bm_api_key', key); opts.headers['X-API-Key'] = key; return fetch(url, opts); } } return r; } async function loadAll() { const [sumR, hostsR] = await Promise.all([fetch(`${API}/api/summary`), fetch(`${API}/api/hosts`)]); const sum = await sumR.json(); allHosts = await hostsR.json(); renderDashboard(sum); renderAlerts(); renderHostGrid(); document.getElementById('lastScan').textContent = new Date().toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'}); // System status indicator const ss = document.getElementById('sysStatus'); if (sum.error > 0) { ss.innerHTML = 'errorErrors Active'; } else if (sum.stale > 0) { ss.innerHTML = 'warningStale Hosts'; } else { ss.innerHTML = 'cloud_doneAll Systems OK'; } } // ── Dashboard ───────────────────────────────────────────── function renderDashboard(sum) { document.getElementById('mOk').textContent = `${sum.ok}/${sum.total_hosts}`; document.getElementById('mSize').textContent = fmtBytes(sum.today_size); document.getElementById('mWarn').textContent = sum.error + sum.stale; const wc = document.getElementById('mWarnCard'); wc.className = 'bg-surface-container-low p-6 rounded-xl' + ((sum.error + sum.stale > 0) ? ' border border-error/20' : ''); // Latest backup const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup)); if (sorted.length) { document.getElementById('mLatest').textContent = sorted[0].last_status === 'ok' ? 'Success' : 'Error'; document.getElementById('mLatestHost').textContent = sorted[0].name; } // Cluster list const cl = document.getElementById('clusterList'); const groups = { ok: [], stale: [], error: [], disabled: [] }; allHosts.forEach(h => groups[h.status]?.push(h)); let html = ''; if (groups.error.length) { html += clusterGroup('ERRORS', groups.error, 'error'); } if (groups.stale.length) { html += clusterGroup('STALE', groups.stale, 'tertiary'); } if (groups.ok.length) { html += clusterGroup('OPERATIONAL', groups.ok, 'secondary'); } if (groups.disabled.length) { html += clusterGroup('DISABLED', groups.disabled, 'outline'); } cl.innerHTML = html; // Live stream renderLiveStream(); loadVolumeChart(); } function clusterGroup(label, hosts, color) { return `
${label}
${hosts.map(h => `
${h.name}
schedule ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}
${h.status.toUpperCase()}
`).join('')}
`; } function renderLiveStream() { const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup)).slice(0, 8); const ls = document.getElementById('liveStream'); ls.innerHTML = sorted.map(h => { const t = new Date(h.last_backup).toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit',second:'2-digit'}); const isErr = h.last_status !== 'ok'; return `
${t}
${isErr ? 'ERROR: ' : ''}Backup for ${h.name} ${isErr ? 'failed' : 'completed successfully'}. ${h.last_message ? `${h.last_message}` : ''}
`; }).join(''); } async function loadVolumeChart() { // Aggregate daily totals from all hosts const days = []; for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(d.toISOString().split('T')[0]); } const dailyTotals = {}; days.forEach(d => dailyTotals[d] = 0); // Fetch calendar for top hosts (limit to avoid too many requests) const topHosts = allHosts.slice(0, 10); const cals = await Promise.all(topHosts.map(h => fetch(`${API}/api/calendar/${h.name}?days=30`).then(r => r.json()))); cals.forEach(cal => { Object.entries(cal).forEach(([day, data]) => { if (dailyTotals[day] !== undefined) dailyTotals[day] += data.total_size; }); }); const values = days.map(d => dailyTotals[d]); const max = Math.max(...values, 1); const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${100 - (v / max) * 80}`); const pathD = 'M' + points.join(' L'); const fillD = pathD + ` L100,100 L0,100 Z`; document.getElementById('chartSvg').innerHTML = ` ${[20,40,60,80].map(y => ``).join('')} `; document.getElementById('chartSvg').setAttribute('viewBox', '0 0 100 100'); document.getElementById('chartSvg').setAttribute('preserveAspectRatio', 'none'); } // ── Alerts ──────────────────────────────────────────────── function renderAlerts() { const issues = allHosts.filter(h => h.status === 'error' || h.status === 'stale'); document.getElementById('aCrit').textContent = String(allHosts.filter(h => h.status === 'error').length).padStart(2, '0'); document.getElementById('aStale').textContent = String(allHosts.filter(h => h.status === 'stale').length).padStart(2, '0'); const al = document.getElementById('alertList'); if (!issues.length) { al.innerHTML = '
No active alerts – all systems operational ✓
'; return; } al.innerHTML = issues.map(h => { const isCrit = h.status === 'error'; const color = isCrit ? 'error' : 'tertiary'; const icon = isCrit ? 'error' : 'warning'; const label = isCrit ? 'CRITICAL' : 'STALE'; return `
${icon}

${isCrit ? 'Backup Failed' : 'Backup Overdue'} – ${h.name}

${label}
dns ${h.name} schedule ${h.last_backup ? timeAgo(h.last_backup) : 'Never'} ${h.last_message ? `${h.last_message}` : `${Math.round(h.age_hours)}h without backup`}
`; }).join(''); } // ── Host Grid ───────────────────────────────────────────── function renderHostGrid() { const grid = document.getElementById('hostGrid'); grid.innerHTML = allHosts.map(h => `
${h.name}
${h.status.toUpperCase()}
Last Backup${h.last_backup ? timeAgo(h.last_backup) : 'Never'}
7d Backups${h.backup_count_7d}
Avg Duration${fmtDuration(h.avg_duration_7d)}
7d Volume${fmtBytes(h.total_size_7d)}
`).join(''); } // ── Host Detail Drawer ──────────────────────────────────── async function openHost(name) { document.getElementById('drawerTitle').textContent = name; document.getElementById('drawerBg').classList.remove('opacity-0','pointer-events-none'); document.getElementById('drawer').classList.remove('translate-x-full'); const body = document.getElementById('drawerBody'); body.innerHTML = '
Loading...
'; const [histR, calR] = await Promise.all([fetch(`${API}/api/history/${name}?days=30`), fetch(`${API}/api/calendar/${name}?days=30`)]); const history = await histR.json(); const calendar = await calR.json(); const host = allHosts.find(h => h.name === name) || {}; const totalSize = history.reduce((s,e) => s + e.original_size, 0); const avgDur = history.length ? Math.round(history.reduce((s,e) => s + e.duration_sec, 0) / history.length) : 0; const rate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0; body.innerHTML = `
${history.length}
Backups
${rate}%
Success
${fmtDuration(avgDur)}
Avg Duration

30-Day Calendar

${buildCalendar(calendar)}

Data Volume

${buildSizeChart(history)}

Recent Backups

${history.slice(0, 15).map(e => `
${new Date(e.timestamp).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})} ${fmtDuration(e.duration_sec)} ${fmtBytes(e.original_size)} ${e.nfiles_new ? `+${e.nfiles_new}` : ''}
`).join('')}
`; } function buildCalendar(cal) { const days = []; for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); const key = d.toISOString().split('T')[0]; const data = cal[key]; const num = d.getDate(); if (!data) { days.push(`
${num}
`); } else { const cls = data.has_error ? 'bg-error/20 text-error' : 'bg-secondary/20 text-secondary'; days.push(`
${num}
`); } } return days.join(''); } function buildSizeChart(history) { const byDay = {}; history.forEach(e => { const d = e.timestamp.split('T')[0]; byDay[d] = (byDay[d]||0) + e.original_size; }); const days = []; for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate()-i); days.push({key:d.toISOString().split('T')[0], size: byDay[d.toISOString().split('T')[0]]||0}); } const max = Math.max(...days.map(d=>d.size), 1); return days.map(d => { const h = d.size ? Math.max(6, (d.size/max)*100) : 4; return `
`; }).join(''); } function closeDrawer() { document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none'); document.getElementById('drawer').classList.add('translate-x-full'); } // ── Modal ───────────────────────────────────────────────── function openAddHost() { document.getElementById('modalTitle').textContent = 'Add Host'; document.getElementById('formMode').value = 'add'; document.getElementById('formName').value = ''; document.getElementById('formName').disabled = false; document.getElementById('formKumaUrl').value = ''; document.getElementById('formEnabled').checked = true; openModal(); } async function openEditHost(name) { closeDrawer(); const h = allHosts.find(x => x.name === name); if (!h) return; document.getElementById('modalTitle').textContent = `Edit: ${name}`; document.getElementById('formMode').value = 'edit'; document.getElementById('formName').value = h.name; document.getElementById('formName').disabled = true; document.getElementById('formKumaUrl').value = h.kuma_push_url || ''; document.getElementById('formEnabled').checked = h.enabled; openModal(); } async function saveHost(e) { e.preventDefault(); const mode = document.getElementById('formMode').value; const name = document.getElementById('formName').value.trim(); const kuma = document.getElementById('formKumaUrl').value.trim(); const enabled = document.getElementById('formEnabled').checked; if (mode === 'add') { await apiFetch(`${API}/api/hosts`, { method:'POST', headers:authHeaders(), body:JSON.stringify({name, kuma_push_url:kuma}) }); toast(`${name} added`); } else { await apiFetch(`${API}/api/hosts/${name}`, { method:'PUT', headers:authHeaders(), body:JSON.stringify({kuma_push_url:kuma, enabled}) }); toast(`${name} updated`); } closeModal(); loadAll(); } async function confirmDelete(name) { if (!confirm(`Delete "${name}" and all history?`)) return; await apiFetch(`${API}/api/hosts/${name}`, { method:'DELETE', headers:authHeaders() }); toast(`${name} deleted`); closeDrawer(); loadAll(); } function openModal() { document.getElementById('modalBg').classList.remove('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.remove('scale-95','opacity-0','pointer-events-none'); } function closeModal() { document.getElementById('modalBg').classList.add('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.add('scale-95','opacity-0','pointer-events-none'); } // ── Navigation ──────────────────────────────────────────── function showPage(page) { currentPage = page; ['dashboard','alerts','hosts','config'].forEach(p => { document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page); // Nav highlights const nav = document.getElementById(`nav-${p}`); const side = document.getElementById(`side-${p}`); if (nav) { nav.className = p === page ? 'text-sm font-bold tracking-tight text-blue-400 px-3 py-1 rounded-lg font-headline' : 'text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline'; } if (side) { side.className = p === page ? 'flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-600/10 text-blue-400 border-r-2 border-blue-500 font-headline text-sm font-semibold' : 'flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-900/80 transition-all font-headline text-sm font-semibold'; } }); } // ── Toast ───────────────────────────────────────────────── function toast(msg) { const t = document.createElement('div'); t.className = 'glass px-5 py-3 rounded-xl text-sm font-medium text-white shadow-2xl flex items-center gap-2 animate-[slideIn_0.3s_ease-out]'; t.innerHTML = `check_circle ${msg}`; document.getElementById('toasts').appendChild(t); setTimeout(() => t.remove(), 4000); } // ── Helpers ─────────────────────────────────────────────── function fmtBytes(b) { if (!b) return '0 B'; const u = ['B','KB','MB','GB','TB']; const i = Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i>0?1:0)+' '+u[i]; } function fmtDuration(s) { if (!s) return '–'; if (s<60) return s+'s'; if (s<3600) return Math.floor(s/60)+'m'; return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; } function timeAgo(iso) { const d=(Date.now()-new Date(iso).getTime())/1000; if(d<60) return 'just now'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; } function statusChipClass(s) { return { ok:'bg-secondary/10 text-secondary border border-secondary/20', error:'bg-error/10 text-error border border-error/20', stale:'bg-tertiary/10 text-tertiary border border-tertiary/20', disabled:'bg-outline/10 text-outline border border-outline/20' }[s] || ''; }