473 lines
20 KiB
JavaScript
473 lines
20 KiB
JavaScript
/* 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 = '<span class="status-dot status--error" aria-hidden="true"></span><span>Nicht erreichbar</span>';
|
||
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 = '<span class="status-dot status--error" aria-hidden="true"></span><span>Fehler aktiv</span>';
|
||
} else if (summary.stale > 0) {
|
||
state.className = 'system-state status--stale';
|
||
state.innerHTML = '<span class="status-dot status--stale" aria-hidden="true"></span><span>Hosts überfällig</span>';
|
||
} else {
|
||
state.className = 'system-state status--ok';
|
||
state.innerHTML = '<span class="status-dot status--ok" aria-hidden="true"></span><span>Betriebsbereit</span>';
|
||
}
|
||
}
|
||
|
||
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 `
|
||
<div class="cluster-group">
|
||
<div class="cluster-heading"><span class="status-dot status--${status}"></span>${label} · ${hosts.length}</div>
|
||
${hosts.map(host => `
|
||
<button type="button" class="cluster-row" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">
|
||
<span class="status-dot status--${host.status}"></span>
|
||
<strong>${escapeHtml(host.name)}</strong>
|
||
<small>${host.last_backup ? timeAgo(host.last_backup) : 'noch nie'}</small>
|
||
</button>
|
||
`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
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 = '<p class="empty-state">Noch keine Backup-Meldungen.</p>';
|
||
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 `
|
||
<div class="event-row">
|
||
<time class="event-time">${time}</time>
|
||
<span class="status-dot status--${failed ? 'error' : 'ok'}"></span>
|
||
<span class="event-message"><strong>${escapeHtml(host.name)}</strong> ${failed ? 'hat einen Fehler gemeldet' : 'wurde erfolgreich gesichert'}</span>
|
||
<span class="event-detail" title="${escapeAttr(host.last_message || '')}">${escapeHtml(host.last_message || '')}</span>
|
||
</div>`;
|
||
}).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 = `
|
||
<defs>
|
||
<linearGradient id="volume-fill" x1="0" x2="0" y1="0" y2="1">
|
||
<stop offset="0%" stop-color="#7abf8a" stop-opacity="0.24"/>
|
||
<stop offset="100%" stop-color="#7abf8a" stop-opacity="0"/>
|
||
</linearGradient>
|
||
</defs>
|
||
${[20, 40, 60, 80].map(y => `<line x1="0" y1="${y}" x2="100" y2="${y}" stroke="#2c312c" stroke-width="0.35"/>`).join('')}
|
||
<path d="${fill}" fill="url(#volume-fill)"/>
|
||
<path d="${path}" fill="none" stroke="#7abf8a" stroke-width="1.2" vector-effect="non-scaling-stroke"/>
|
||
`;
|
||
}
|
||
|
||
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 = '<p class="empty-state">Keine offenen Meldungen. Alle aktiven Hosts sind im Zeitfenster.</p>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = issues.map(host => {
|
||
const failed = host.status === 'error';
|
||
return `
|
||
<article class="alert-row">
|
||
<span class="status-dot status--${failed ? 'error' : 'stale'}"></span>
|
||
<div class="alert-copy">
|
||
<h3>${failed ? 'Sicherung fehlgeschlagen' : 'Sicherung überfällig'} · ${escapeHtml(host.name)}</h3>
|
||
<div class="alert-meta">
|
||
<span>${host.last_backup ? timeAgo(host.last_backup) : 'noch keine Sicherung'}</span>
|
||
<span>${escapeHtml(host.last_message || `${Math.round(host.age_hours || 0)} Stunden ohne Meldung`)}</span>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="button" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">Details</button>
|
||
</article>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderHostGrid() {
|
||
const grid = document.getElementById('hostGrid');
|
||
grid.innerHTML = allHosts.map(host => `
|
||
<button type="button" class="host-card ${host.status === 'disabled' ? 'is-disabled' : ''}" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">
|
||
<span class="host-card-header">
|
||
<strong>${escapeHtml(host.name)}</strong>
|
||
<span class="status-pill status--${host.status}">${statusLabels[host.status] || host.status}</span>
|
||
</span>
|
||
<dl class="host-facts">
|
||
<div><dt>Letzte Sicherung</dt><dd>${host.last_backup ? timeAgo(host.last_backup) : 'noch nie'}</dd></div>
|
||
<div><dt>Backups, 7 Tage</dt><dd>${host.backup_count_7d}</dd></div>
|
||
<div><dt>Ø Laufzeit</dt><dd>${fmtDuration(host.avg_duration_7d)}</dd></div>
|
||
<div><dt>Volumen, 7 Tage</dt><dd>${fmtBytes(host.total_size_7d)}</dd></div>
|
||
</dl>
|
||
</button>
|
||
`).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 = '<p class="empty-state">Details werden geladen …</p>';
|
||
|
||
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 = `
|
||
<div class="drawer-stats">
|
||
<div class="drawer-stat"><strong>${history.length}</strong><span>Sicherungen</span></div>
|
||
<div class="drawer-stat"><strong>${successRate}%</strong><span>Erfolgreich</span></div>
|
||
<div class="drawer-stat"><strong>${fmtBytes(totalSize)}</strong><span>Volumen</span></div>
|
||
</div>
|
||
|
||
<section class="detail-section">
|
||
<h3>30 Tage</h3>
|
||
<div class="calendar-grid">${buildCalendar(calendar)}</div>
|
||
</section>
|
||
|
||
<section class="detail-section">
|
||
<h3>Datenvolumen</h3>
|
||
<div class="size-bars">${buildSizeChart(history)}</div>
|
||
</section>
|
||
|
||
<section class="detail-section">
|
||
<h3>Letzte Sicherungen</h3>
|
||
<div class="history-list">
|
||
${history.slice(0, 15).map(entry => `
|
||
<div class="history-row">
|
||
<time class="history-date">${new Date(entry.timestamp).toLocaleString('de-DE', {day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'})}</time>
|
||
<span class="status-dot status--${entry.status === 'ok' ? 'ok' : 'error'}"></span>
|
||
<span>${fmtDuration(entry.duration_sec)}</span>
|
||
<span class="history-size">${fmtBytes(entry.original_size)}</span>
|
||
<span class="history-files">${entry.nfiles_new ? `+${entry.nfiles_new}` : ''}</span>
|
||
</div>
|
||
`).join('') || '<p class="empty-state">Keine Einträge im Zeitraum.</p>'}
|
||
</div>
|
||
</section>
|
||
|
||
<div class="drawer-actions">
|
||
<button type="button" class="button" onclick="openEditHost(document.getElementById('drawerTitle').textContent)">Bearbeiten</button>
|
||
<button type="button" class="button button--danger" onclick="confirmDelete(document.getElementById('drawerTitle').textContent)">Löschen</button>
|
||
</div>`;
|
||
} catch (error) {
|
||
body.innerHTML = '<p class="empty-state">Die Host-Details konnten nicht geladen werden.</p>';
|
||
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(`<div class="calendar-day" title="${key}: keine Sicherung">${date.getDate()}</div>`);
|
||
} else {
|
||
const state = data.has_error ? 'has-error' : 'has-backup';
|
||
days.push(`<div class="calendar-day ${state}" title="${key}: ${data.count} Sicherung(en), ${fmtBytes(data.total_size)}">${date.getDate()}</div>`);
|
||
}
|
||
}
|
||
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 `<div class="size-bar ${day.size ? '' : 'is-empty'}" style="height:${height}%" title="${day.key}: ${fmtBytes(day.size)}"></div>`;
|
||
}).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, ''');
|
||
}
|