/* The Sentinel – Backup Monitor */ const API = ''; let apiKey = localStorage.getItem('bm_api_key') || ''; let allHosts = []; let currentPage = 'dashboard'; const statusLabels = { ok: 'OK', stale: 'überfällig', error: 'Fehler', disabled: 'aus', }; document.addEventListener('DOMContentLoaded', () => { loadAll(); setInterval(loadAll, 30000); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); closeModal(); } }); function authHeaders() { const headers = {'Content-Type': 'application/json'}; if (apiKey) headers['X-API-Key'] = apiKey; return headers; } async function apiFetch(url, options = {}) { if (!options.headers) options.headers = {}; if (apiKey) options.headers['X-API-Key'] = apiKey; let response = await fetch(url, options); if (response.status === 401) { const key = prompt('API-Schlüssel eingeben:'); if (key) { apiKey = key; localStorage.setItem('bm_api_key', key); options.headers['X-API-Key'] = key; response = await fetch(url, options); } } return response; } async function loadAll() { try { const [summaryResponse, hostsResponse] = await Promise.all([ fetch(`${API}/api/summary`), fetch(`${API}/api/hosts`), ]); if (!summaryResponse.ok || !hostsResponse.ok) throw new Error('API nicht erreichbar'); const summary = await summaryResponse.json(); allHosts = await hostsResponse.json(); renderDashboard(summary); renderAlerts(); renderHostGrid(); document.getElementById('lastScan').textContent = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', }); renderSystemState(summary); } catch (error) { const state = document.getElementById('sysStatus'); state.className = 'system-state status--error'; state.innerHTML = 'Nicht erreichbar'; console.error('Sentinel konnte nicht aktualisiert werden:', error); } } function renderSystemState(summary) { const state = document.getElementById('sysStatus'); if (summary.error > 0) { state.className = 'system-state status--error'; state.innerHTML = 'Fehler aktiv'; } else if (summary.stale > 0) { state.className = 'system-state status--stale'; state.innerHTML = 'Hosts überfällig'; } else { state.className = 'system-state status--ok'; state.innerHTML = 'Betriebsbereit'; } } function renderDashboard(summary) { document.getElementById('mOk').textContent = `${summary.ok}/${summary.total_hosts}`; document.getElementById('mSize').textContent = fmtBytes(summary.today_size); const issueCount = summary.error + summary.stale; document.getElementById('mWarn').textContent = issueCount; document.getElementById('mWarnCard').classList.toggle('has-issues', issueCount > 0); const sorted = [...allHosts] .filter(host => host.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' ? 'Erfolgreich' : 'Fehler'; document.getElementById('mLatestHost').textContent = sorted[0].name; } else { document.getElementById('mLatest').textContent = '–'; document.getElementById('mLatestHost').textContent = 'Noch keine Meldung'; } const groups = {ok: [], stale: [], error: [], disabled: []}; allHosts.forEach(host => groups[host.status]?.push(host)); const parts = []; if (groups.error.length) parts.push(clusterGroup('Fehler', groups.error, 'error')); if (groups.stale.length) parts.push(clusterGroup('Überfällig', groups.stale, 'stale')); if (groups.ok.length) parts.push(clusterGroup('In Ordnung', groups.ok, 'ok')); if (groups.disabled.length) parts.push(clusterGroup('Deaktiviert', groups.disabled, 'disabled')); document.getElementById('clusterList').innerHTML = parts.join(''); renderLiveStream(); loadVolumeChart(); } function clusterGroup(label, hosts, status) { return `
${label} · ${hosts.length}
${hosts.map(host => ` `).join('')}
`; } function renderLiveStream() { const sorted = [...allHosts] .filter(host => host.last_backup) .sort((a, b) => new Date(b.last_backup) - new Date(a.last_backup)) .slice(0, 8); const list = document.getElementById('liveStream'); if (!sorted.length) { list.innerHTML = '

Noch keine Backup-Meldungen.

'; return; } list.innerHTML = sorted.map(host => { const time = new Date(host.last_backup).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit', }); const failed = host.last_status !== 'ok'; return `
${escapeHtml(host.name)} ${failed ? 'hat einen Fehler gemeldet' : 'wurde erfolgreich gesichert'} ${escapeHtml(host.last_message || '')}
`; }).join(''); } async function loadVolumeChart() { const days = []; for (let offset = 29; offset >= 0; offset -= 1) { const date = new Date(); date.setDate(date.getDate() - offset); days.push(date.toISOString().split('T')[0]); } const dailyTotals = Object.fromEntries(days.map(day => [day, 0])); try { const topHosts = allHosts.slice(0, 10); const calendars = await Promise.all(topHosts.map(host => fetch(`${API}/api/calendar/${encodeURIComponent(host.name)}?days=30`).then(response => response.json()) )); calendars.forEach(calendar => { Object.entries(calendar).forEach(([day, data]) => { if (dailyTotals[day] !== undefined) dailyTotals[day] += Number(data.total_size || 0); }); }); } catch (error) { console.error('Volumenverlauf konnte nicht geladen werden:', error); } const values = days.map(day => dailyTotals[day]); const maximum = Math.max(...values, 1); const points = values.map((value, index) => `${(index / (values.length - 1)) * 100},${94 - (value / maximum) * 76}`); const path = `M${points.join(' L')}`; const fill = `${path} L100,100 L0,100 Z`; document.getElementById('chartSvg').innerHTML = ` ${[20, 40, 60, 80].map(y => ``).join('')} `; } function renderAlerts() { const issues = allHosts.filter(host => host.status === 'error' || host.status === 'stale'); document.getElementById('aCrit').textContent = allHosts.filter(host => host.status === 'error').length; document.getElementById('aStale').textContent = allHosts.filter(host => host.status === 'stale').length; const list = document.getElementById('alertList'); if (!issues.length) { list.innerHTML = '

Keine offenen Meldungen. Alle aktiven Hosts sind im Zeitfenster.

'; return; } list.innerHTML = issues.map(host => { const failed = host.status === 'error'; return `

${failed ? 'Sicherung fehlgeschlagen' : 'Sicherung überfällig'} · ${escapeHtml(host.name)}

${host.last_backup ? timeAgo(host.last_backup) : 'noch keine Sicherung'} ${escapeHtml(host.last_message || `${Math.round(host.age_hours || 0)} Stunden ohne Meldung`)}
`; }).join(''); } function renderHostGrid() { const grid = document.getElementById('hostGrid'); grid.innerHTML = allHosts.map(host => ` `).join(''); } async function openHost(name) { const drawer = document.getElementById('drawer'); document.getElementById('drawerTitle').textContent = name; document.getElementById('drawerBg').classList.remove('is-hidden'); drawer.classList.remove('is-closed'); drawer.setAttribute('aria-hidden', 'false'); const body = document.getElementById('drawerBody'); body.innerHTML = '

Details werden geladen …

'; try { const [historyResponse, calendarResponse] = await Promise.all([ fetch(`${API}/api/history/${encodeURIComponent(name)}?days=30`), fetch(`${API}/api/calendar/${encodeURIComponent(name)}?days=30`), ]); const history = await historyResponse.json(); const calendar = await calendarResponse.json(); const totalSize = history.reduce((sum, entry) => sum + Number(entry.original_size || 0), 0); const successRate = history.length ? Math.round(history.filter(entry => entry.status === 'ok').length / history.length * 100) : 0; body.innerHTML = `
${history.length}Sicherungen
${successRate}%Erfolgreich
${fmtBytes(totalSize)}Volumen

30 Tage

${buildCalendar(calendar)}

Datenvolumen

${buildSizeChart(history)}

Letzte Sicherungen

${history.slice(0, 15).map(entry => `
${fmtDuration(entry.duration_sec)} ${fmtBytes(entry.original_size)} ${entry.nfiles_new ? `+${entry.nfiles_new}` : ''}
`).join('') || '

Keine Einträge im Zeitraum.

'}
`; } catch (error) { body.innerHTML = '

Die Host-Details konnten nicht geladen werden.

'; console.error('Host-Details konnten nicht geladen werden:', error); } } function buildCalendar(calendar) { const days = []; for (let offset = 29; offset >= 0; offset -= 1) { const date = new Date(); date.setDate(date.getDate() - offset); const key = date.toISOString().split('T')[0]; const data = calendar[key]; if (!data) { days.push(`
${date.getDate()}
`); } else { const state = data.has_error ? 'has-error' : 'has-backup'; days.push(`
${date.getDate()}
`); } } return days.join(''); } function buildSizeChart(history) { const byDay = {}; history.forEach(entry => { const day = entry.timestamp.split('T')[0]; byDay[day] = (byDay[day] || 0) + Number(entry.original_size || 0); }); const days = []; for (let offset = 29; offset >= 0; offset -= 1) { const date = new Date(); date.setDate(date.getDate() - offset); const key = date.toISOString().split('T')[0]; days.push({key, size: byDay[key] || 0}); } const maximum = Math.max(...days.map(day => day.size), 1); return days.map(day => { const height = day.size ? Math.max(6, (day.size / maximum) * 100) : 3; return `
`; }).join(''); } function closeDrawer() { const drawer = document.getElementById('drawer'); document.getElementById('drawerBg').classList.add('is-hidden'); drawer.classList.add('is-closed'); drawer.setAttribute('aria-hidden', 'true'); } function openAddHost() { document.getElementById('modalTitle').textContent = 'Host hinzufügen'; document.getElementById('formMode').value = 'add'; document.getElementById('formName').value = ''; document.getElementById('formName').disabled = false; document.getElementById('formKumaUrl').value = ''; document.getElementById('formEnabled').checked = true; openModal(); } function openEditHost(name) { closeDrawer(); const host = allHosts.find(item => item.name === name); if (!host) return; document.getElementById('modalTitle').textContent = `${name} bearbeiten`; document.getElementById('formMode').value = 'edit'; document.getElementById('formName').value = host.name; document.getElementById('formName').disabled = true; document.getElementById('formKumaUrl').value = host.kuma_push_url || ''; document.getElementById('formEnabled').checked = host.enabled; openModal(); } async function saveHost(event) { event.preventDefault(); const mode = document.getElementById('formMode').value; const name = document.getElementById('formName').value.trim(); const kumaUrl = 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: kumaUrl}), }); toast(`${name} wurde hinzugefügt`); } else { await apiFetch(`${API}/api/hosts/${encodeURIComponent(name)}`, { method: 'PUT', headers: authHeaders(), body: JSON.stringify({kuma_push_url: kumaUrl, enabled}), }); toast(`${name} wurde aktualisiert`); } closeModal(); loadAll(); } async function confirmDelete(name) { if (!confirm(`Host "${name}" einschließlich Historie löschen?`)) return; await apiFetch(`${API}/api/hosts/${encodeURIComponent(name)}`, {method: 'DELETE', headers: authHeaders()}); toast(`${name} wurde gelöscht`); closeDrawer(); loadAll(); } function openModal() { const modal = document.getElementById('modal'); document.getElementById('modalBg').classList.remove('is-hidden'); modal.classList.remove('is-closed'); modal.setAttribute('aria-hidden', 'false'); setTimeout(() => document.getElementById('formName').focus(), 0); } function closeModal() { const modal = document.getElementById('modal'); document.getElementById('modalBg').classList.add('is-hidden'); modal.classList.add('is-closed'); modal.setAttribute('aria-hidden', 'true'); } function showPage(page) { currentPage = page; ['dashboard', 'alerts', 'hosts', 'config'].forEach(item => { document.getElementById(`page-${item}`).classList.toggle('hidden', item !== page); const nav = document.getElementById(`nav-${item}`); nav.classList.toggle('is-active', item === page); if (item === page) nav.setAttribute('aria-current', 'page'); else nav.removeAttribute('aria-current'); }); window.scrollTo({top: 0, behavior: 'smooth'}); } function toast(message) { const element = document.createElement('div'); element.className = 'toast'; element.textContent = message; document.getElementById('toasts').appendChild(element); setTimeout(() => element.remove(), 4000); } function fmtBytes(bytes) { const value = Number(bytes || 0); if (!value) return '0 B'; const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); const number = value / Math.pow(1024, index); return `${number.toLocaleString('de-DE', {maximumFractionDigits: index > 0 ? 1 : 0})} ${units[index]}`; } function fmtDuration(seconds) { const value = Number(seconds || 0); if (value <= 0) return '–'; if (value < 60) return `${value.toLocaleString('de-DE', {maximumFractionDigits: 1})} s`; if (value < 3600) return `${Math.floor(value / 60)} min`; return `${Math.floor(value / 3600)} h ${Math.floor((value % 3600) / 60)} min`; } function timeAgo(iso) { const seconds = (Date.now() - new Date(iso).getTime()) / 1000; if (seconds < 60) return 'gerade eben'; if (seconds < 3600) return `vor ${Math.floor(seconds / 60)} min`; if (seconds < 86400) return `vor ${Math.floor(seconds / 3600)} h`; return `vor ${Math.floor(seconds / 86400)} T.`; } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"]/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', })[character]); } function escapeAttr(value) { return escapeHtml(value).replace(/'/g, '''); }