monitoring/backup-monitor/static/js/app.js

473 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;',
})[character]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, '&#39;');
}