/* 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, ''');
}