Sentinel-Oberfläche beruhigen #1

Merged
sascha merged 4 commits from sentinel-calm-ui-20260729 into main 2026-07-29 15:39:02 +02:00
Showing only changes of commit ff46387a43 - Show all commits

View file

@ -1,268 +1,323 @@
/* ── The Sentinel Backup Monitor Frontend ────────────────── */ /* The Sentinel Backup Monitor */
const API = ''; const API = '';
let apiKey = localStorage.getItem('bm_api_key') || ''; let apiKey = localStorage.getItem('bm_api_key') || '';
let allHosts = []; let allHosts = [];
let currentPage = 'dashboard'; let currentPage = 'dashboard';
// ── Init ────────────────────────────────────────────────── const statusLabels = {
ok: 'OK',
stale: 'überfällig',
error: 'Fehler',
disabled: 'aus',
};
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadAll(); loadAll();
setInterval(loadAll, 30000); setInterval(loadAll, 30000);
}); });
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeDrawer();
closeModal();
}
});
function authHeaders() { function authHeaders() {
const h = {'Content-Type': 'application/json'}; const headers = {'Content-Type': 'application/json'};
if (apiKey) h['X-API-Key'] = apiKey; if (apiKey) headers['X-API-Key'] = apiKey;
return h; return headers;
} }
async function apiFetch(url, opts = {}) { async function apiFetch(url, options = {}) {
if (!opts.headers) opts.headers = {}; if (!options.headers) options.headers = {};
if (apiKey) opts.headers['X-API-Key'] = apiKey; if (apiKey) options.headers['X-API-Key'] = apiKey;
const r = await fetch(url, opts); let response = await fetch(url, options);
if (r.status === 401) { if (response.status === 401) {
const key = prompt('🔑 API-Key eingeben:'); const key = prompt('API-Schlüssel eingeben:');
if (key) { apiKey = key; localStorage.setItem('bm_api_key', key); opts.headers['X-API-Key'] = key; return fetch(url, opts); } if (key) {
apiKey = key;
localStorage.setItem('bm_api_key', key);
options.headers['X-API-Key'] = key;
response = await fetch(url, options);
} }
return r; }
return response;
} }
async function loadAll() { async function loadAll() {
const [sumR, hostsR] = await Promise.all([fetch(`${API}/api/summary`), fetch(`${API}/api/hosts`)]); try {
const sum = await sumR.json(); const [summaryResponse, hostsResponse] = await Promise.all([
allHosts = await hostsR.json(); fetch(`${API}/api/summary`),
renderDashboard(sum); 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(); renderAlerts();
renderHostGrid(); renderHostGrid();
document.getElementById('lastScan').textContent = new Date().toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'}); document.getElementById('lastScan').textContent = new Date().toLocaleTimeString('de-DE', {
// System status indicator hour: '2-digit',
const ss = document.getElementById('sysStatus'); minute: '2-digit',
if (sum.error > 0) { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-error" style="font-variation-settings:\'FILL\' 1">error</span><span class="font-headline text-xs font-medium text-error">Errors Active</span>'; } });
else if (sum.stale > 0) { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-tertiary" style="font-variation-settings:\'FILL\' 1">warning</span><span class="font-headline text-xs font-medium text-tertiary">Stale Hosts</span>'; } renderSystemState(summary);
else { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-secondary" style="font-variation-settings:\'FILL\' 1">cloud_done</span><span class="font-headline text-xs font-medium text-slate-300">All Systems OK</span>'; } } 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);
}
} }
// ── Dashboard ───────────────────────────────────────────── function renderSystemState(summary) {
function renderDashboard(sum) { const state = document.getElementById('sysStatus');
document.getElementById('mOk').textContent = `${sum.ok}/${sum.total_hosts}`; if (summary.error > 0) {
document.getElementById('mSize').textContent = fmtBytes(sum.today_size); state.className = 'system-state status--error';
document.getElementById('mWarn').textContent = sum.error + sum.stale; state.innerHTML = '<span class="status-dot status--error" aria-hidden="true"></span><span>Fehler aktiv</span>';
const wc = document.getElementById('mWarnCard'); } else if (summary.stale > 0) {
wc.className = 'bg-surface-container-low p-6 rounded-xl' + ((sum.error + sum.stale > 0) ? ' border border-error/20' : ''); 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>';
}
}
// Latest backup function renderDashboard(summary) {
const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup)); 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) { if (sorted.length) {
document.getElementById('mLatest').textContent = sorted[0].last_status === 'ok' ? 'Success' : 'Error'; document.getElementById('mLatest').textContent = sorted[0].last_status === 'ok' ? 'Erfolgreich' : 'Fehler';
document.getElementById('mLatestHost').textContent = sorted[0].name; document.getElementById('mLatestHost').textContent = sorted[0].name;
} else {
document.getElementById('mLatest').textContent = '';
document.getElementById('mLatestHost').textContent = 'Noch keine Meldung';
} }
// Cluster list const groups = {ok: [], stale: [], error: [], disabled: []};
const cl = document.getElementById('clusterList'); allHosts.forEach(host => groups[host.status]?.push(host));
const groups = { ok: [], stale: [], error: [], disabled: [] }; const parts = [];
allHosts.forEach(h => groups[h.status]?.push(h)); if (groups.error.length) parts.push(clusterGroup('Fehler', groups.error, 'error'));
let html = ''; if (groups.stale.length) parts.push(clusterGroup('Überfällig', groups.stale, 'stale'));
if (groups.error.length) { html += clusterGroup('ERRORS', groups.error, 'error'); } if (groups.ok.length) parts.push(clusterGroup('In Ordnung', groups.ok, 'ok'));
if (groups.stale.length) { html += clusterGroup('STALE', groups.stale, 'tertiary'); } if (groups.disabled.length) parts.push(clusterGroup('Deaktiviert', groups.disabled, 'disabled'));
if (groups.ok.length) { html += clusterGroup('OPERATIONAL', groups.ok, 'secondary'); } document.getElementById('clusterList').innerHTML = parts.join('');
if (groups.disabled.length) { html += clusterGroup('DISABLED', groups.disabled, 'outline'); }
cl.innerHTML = html;
// Live stream
renderLiveStream(); renderLiveStream();
loadVolumeChart(); loadVolumeChart();
} }
function clusterGroup(label, hosts, color) { function clusterGroup(label, hosts, status) {
return ` return `
<div class="mb-4"> <div class="cluster-group">
<div class="flex items-center gap-2 mb-3"><div class="w-1 h-4 bg-${color} rounded-full"></div><span class="text-xs font-black uppercase tracking-widest text-${color}">${label}</span></div> <div class="cluster-heading"><span class="status-dot status--${status}"></span>${label} · ${hosts.length}</div>
${hosts.map(h => ` ${hosts.map(host => `
<div onclick="openHost('${h.name}')" class="flex items-center justify-between px-4 py-3 rounded-lg bg-surface-container hover:bg-surface-container-high transition-all cursor-pointer mb-2"> <button type="button" class="cluster-row" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">
<div> <span class="status-dot status--${host.status}"></span>
<div class="text-sm font-bold text-white font-headline">${h.name}</div> <strong>${escapeHtml(host.name)}</strong>
<div class="text-[11px] text-on-surface-variant flex items-center gap-1"><span class="material-symbols-outlined text-[12px]">schedule</span> ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</div> <small>${host.last_backup ? timeAgo(host.last_backup) : 'noch nie'}</small>
</div> </button>
<span class="px-2 py-0.5 rounded text-[10px] font-black tracking-wider ${statusChipClass(h.status)}">${h.status.toUpperCase()}</span>
</div>
`).join('')} `).join('')}
</div>`; </div>`;
} }
function renderLiveStream() { 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 sorted = [...allHosts]
const ls = document.getElementById('liveStream'); .filter(host => host.last_backup)
ls.innerHTML = sorted.map(h => { .sort((a, b) => new Date(b.last_backup) - new Date(a.last_backup))
const t = new Date(h.last_backup).toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit',second:'2-digit'}); .slice(0, 8);
const isErr = h.last_status !== 'ok'; 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 ` return `
<div class="flex items-center gap-4 px-4 py-3 rounded-lg hover:bg-surface-container transition-colors ${isErr ? 'bg-error-container/5' : ''}"> <div class="event-row">
<span class="text-xs font-mono text-slate-500 w-16 shrink-0">${t}</span> <time class="event-time">${time}</time>
<div class="w-2.5 h-2.5 rounded-full ${isErr ? 'bg-error pulse-err' : 'bg-secondary'} shrink-0"></div> <span class="status-dot status--${failed ? 'error' : 'ok'}"></span>
<span class="text-sm flex-1">${isErr ? '<span class="text-error font-bold">ERROR:</span> ' : ''}Backup for <span class="font-bold text-white">${h.name}</span> ${isErr ? 'failed' : 'completed successfully'}.</span> <span class="event-message"><strong>${escapeHtml(host.name)}</strong> ${failed ? 'hat einen Fehler gemeldet' : 'wurde erfolgreich gesichert'}</span>
${h.last_message ? `<span class="text-[10px] font-mono text-error/80">${h.last_message}</span>` : ''} <span class="event-detail" title="${escapeAttr(host.last_message || '')}">${escapeHtml(host.last_message || '')}</span>
</div>`; </div>`;
}).join(''); }).join('');
} }
async function loadVolumeChart() { async function loadVolumeChart() {
// Aggregate daily totals from all hosts
const days = []; 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]); } for (let offset = 29; offset >= 0; offset -= 1) {
const dailyTotals = {}; const date = new Date();
days.forEach(d => dailyTotals[d] = 0); date.setDate(date.getDate() - offset);
days.push(date.toISOString().split('T')[0]);
}
const dailyTotals = Object.fromEntries(days.map(day => [day, 0]));
// Fetch calendar for top hosts (limit to avoid too many requests) try {
const topHosts = allHosts.slice(0, 10); 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()))); const calendars = await Promise.all(topHosts.map(host =>
cals.forEach(cal => { Object.entries(cal).forEach(([day, data]) => { if (dailyTotals[day] !== undefined) dailyTotals[day] += data.total_size; }); }); fetch(`${API}/api/calendar/${encodeURIComponent(host.name)}?days=30`).then(response => response.json())
));
const values = days.map(d => dailyTotals[d]); calendars.forEach(calendar => {
const max = Math.max(...values, 1); Object.entries(calendar).forEach(([day, data]) => {
const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${100 - (v / max) * 80}`); if (dailyTotals[day] !== undefined) dailyTotals[day] += Number(data.total_size || 0);
const pathD = 'M' + points.join(' L'); });
const fillD = pathD + ` L100,100 L0,100 Z`; });
} 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 = ` document.getElementById('chartSvg').innerHTML = `
<defs><linearGradient id="cg" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#adc6ff" stop-opacity="0.2"/><stop offset="100%" stop-color="#adc6ff" stop-opacity="0"/></linearGradient></defs> <defs>
${[20,40,60,80].map(y => `<line x1="0" y1="${y}" x2="100" y2="${y}" stroke="#1e293b" stroke-width="0.3"/>`).join('')} <linearGradient id="volume-fill" x1="0" x2="0" y1="0" y2="1">
<path d="${fillD}" fill="url(#cg)"/> <stop offset="0%" stop-color="#7abf8a" stop-opacity="0.24"/>
<path d="${pathD}" fill="none" stroke="#adc6ff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <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"/>
`; `;
document.getElementById('chartSvg').setAttribute('viewBox', '0 0 100 100');
document.getElementById('chartSvg').setAttribute('preserveAspectRatio', 'none');
} }
// ── Alerts ────────────────────────────────────────────────
function renderAlerts() { function renderAlerts() {
const issues = allHosts.filter(h => h.status === 'error' || h.status === 'stale'); const issues = allHosts.filter(host => host.status === 'error' || host.status === 'stale');
document.getElementById('aCrit').textContent = String(allHosts.filter(h => h.status === 'error').length).padStart(2, '0'); document.getElementById('aCrit').textContent = allHosts.filter(host => host.status === 'error').length;
document.getElementById('aStale').textContent = String(allHosts.filter(h => h.status === 'stale').length).padStart(2, '0'); document.getElementById('aStale').textContent = allHosts.filter(host => host.status === 'stale').length;
const al = document.getElementById('alertList'); const list = document.getElementById('alertList');
if (!issues.length) { al.innerHTML = '<div class="text-center py-16 text-on-surface-variant text-sm">No active alerts all systems operational ✓</div>'; return; } if (!issues.length) {
list.innerHTML = '<p class="empty-state">Keine offenen Meldungen. Alle aktiven Hosts sind im Zeitfenster.</p>';
return;
}
al.innerHTML = issues.map(h => { list.innerHTML = issues.map(host => {
const isCrit = h.status === 'error'; const failed = host.status === 'error';
const color = isCrit ? 'error' : 'tertiary';
const icon = isCrit ? 'error' : 'warning';
const label = isCrit ? 'CRITICAL' : 'STALE';
return ` return `
<div class="bg-surface-container-low hover:bg-surface-container transition-all rounded-xl group"> <article class="alert-row">
<div class="flex flex-col md:flex-row items-start md:items-center gap-4 px-6 py-5"> <span class="status-dot status--${failed ? 'error' : 'stale'}"></span>
<div class="w-12 h-12 rounded-full bg-${color}/10 flex items-center justify-center shrink-0 ${isCrit ? 'pulse-err' : ''}"> <div class="alert-copy">
<span class="material-symbols-outlined text-${color}" style="font-variation-settings:'FILL' 1">${icon}</span> <h3>${failed ? 'Sicherung fehlgeschlagen' : 'Sicherung überfällig'} · ${escapeHtml(host.name)}</h3>
</div> <div class="alert-meta">
<div class="flex-1"> <span>${host.last_backup ? timeAgo(host.last_backup) : 'noch keine Sicherung'}</span>
<div class="flex items-center gap-3 mb-1"> <span>${escapeHtml(host.last_message || `${Math.round(host.age_hours || 0)} Stunden ohne Meldung`)}</span>
<h3 class="text-white font-bold font-headline">${isCrit ? 'Backup Failed' : 'Backup Overdue'} ${h.name}</h3>
<span class="px-2 py-0.5 rounded text-[10px] font-black bg-${color}/10 text-${color} border border-${color}/20 tracking-wider">${label}</span>
</div>
<div class="flex items-center gap-4 text-xs text-on-surface-variant">
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">dns</span> ${h.name}</span>
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">schedule</span> ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</span>
${h.last_message ? `<span class="text-${color}/80 italic">${h.last_message}</span>` : `<span class="text-${color}/80 italic">${Math.round(h.age_hours)}h without backup</span>`}
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <button type="button" class="button" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">Details</button>
<button onclick="openHost('${h.name}')" class="bg-surface-variant hover:bg-surface-container-highest text-on-surface-variant px-5 py-2 rounded-lg text-xs font-bold transition-all">Details</button> </article>`;
</div>
</div>
</div>`;
}).join(''); }).join('');
} }
// ── Host Grid ─────────────────────────────────────────────
function renderHostGrid() { function renderHostGrid() {
const grid = document.getElementById('hostGrid'); const grid = document.getElementById('hostGrid');
grid.innerHTML = allHosts.map(h => ` grid.innerHTML = allHosts.map(host => `
<div onclick="openHost('${h.name}')" class="bg-surface-container-low hover:bg-surface-container rounded-xl p-6 cursor-pointer transition-all group relative overflow-hidden ${h.status === 'disabled' ? 'opacity-50' : ''}"> <button type="button" class="host-card ${host.status === 'disabled' ? 'is-disabled' : ''}" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">
<div class="absolute top-0 left-0 w-1 h-full rounded-l-xl ${h.status === 'ok' ? 'bg-secondary' : h.status === 'error' ? 'bg-error' : h.status === 'stale' ? 'bg-tertiary' : 'bg-outline'}"></div> <span class="host-card-header">
<div class="flex justify-between items-start mb-4"> <strong>${escapeHtml(host.name)}</strong>
<div class="text-base font-bold text-white font-headline">${h.name}</div> <span class="status-pill status--${host.status}">${statusLabels[host.status] || host.status}</span>
<span class="px-2 py-0.5 rounded text-[10px] font-black tracking-wider ${statusChipClass(h.status)}">${h.status.toUpperCase()}</span> </span>
</div> <dl class="host-facts">
<div class="grid grid-cols-2 gap-3 text-xs"> <div><dt>Letzte Sicherung</dt><dd>${host.last_backup ? timeAgo(host.last_backup) : 'noch nie'}</dd></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">Last Backup</span><span class="font-semibold text-white">${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</span></div> <div><dt>Backups, 7 Tage</dt><dd>${host.backup_count_7d}</dd></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">7d Backups</span><span class="font-semibold text-white">${h.backup_count_7d}</span></div> <div><dt>Ø Laufzeit</dt><dd>${fmtDuration(host.avg_duration_7d)}</dd></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">Avg Duration</span><span class="font-semibold text-white">${fmtDuration(h.avg_duration_7d)}</span></div> <div><dt>Volumen, 7 Tage</dt><dd>${fmtBytes(host.total_size_7d)}</dd></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">7d Volume</span><span class="font-semibold text-white">${fmtBytes(h.total_size_7d)}</span></div> </dl>
</div> </button>
</div>
`).join(''); `).join('');
} }
// ── Host Detail Drawer ────────────────────────────────────
async function openHost(name) { async function openHost(name) {
const drawer = document.getElementById('drawer');
document.getElementById('drawerTitle').textContent = name; document.getElementById('drawerTitle').textContent = name;
document.getElementById('drawerBg').classList.remove('opacity-0','pointer-events-none'); document.getElementById('drawerBg').classList.remove('is-hidden');
document.getElementById('drawer').classList.remove('translate-x-full'); drawer.classList.remove('is-closed');
drawer.setAttribute('aria-hidden', 'false');
const body = document.getElementById('drawerBody'); const body = document.getElementById('drawerBody');
body.innerHTML = '<div class="text-center py-12 text-on-surface-variant">Loading...</div>'; body.innerHTML = '<p class="empty-state">Details werden geladen …</p>';
const [histR, calR] = await Promise.all([fetch(`${API}/api/history/${name}?days=30`), fetch(`${API}/api/calendar/${name}?days=30`)]); try {
const history = await histR.json(); const [historyResponse, calendarResponse] = await Promise.all([
const calendar = await calR.json(); fetch(`${API}/api/history/${encodeURIComponent(name)}?days=30`),
const host = allHosts.find(h => h.name === name) || {}; fetch(`${API}/api/calendar/${encodeURIComponent(name)}?days=30`),
]);
const totalSize = history.reduce((s,e) => s + e.original_size, 0); const history = await historyResponse.json();
const avgDur = history.length ? Math.round(history.reduce((s,e) => s + e.duration_sec, 0) / history.length) : 0; const calendar = await calendarResponse.json();
const rate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0; 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 = ` body.innerHTML = `
<!-- Stats --> <div class="drawer-stats">
<div class="grid grid-cols-3 gap-3 mb-6"> <div class="drawer-stat"><strong>${history.length}</strong><span>Sicherungen</span></div>
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-primary">${history.length}</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Backups</div></div> <div class="drawer-stat"><strong>${successRate}%</strong><span>Erfolgreich</span></div>
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-secondary">${rate}%</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Success</div></div> <div class="drawer-stat"><strong>${fmtBytes(totalSize)}</strong><span>Volumen</span></div>
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-primary">${fmtDuration(avgDur)}</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Avg Duration</div></div>
</div> </div>
<!-- Calendar --> <section class="detail-section">
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">30-Day Calendar</h4> <h3>30 Tage</h3>
<div class="grid grid-cols-7 gap-1.5 mb-6">${buildCalendar(calendar)}</div> <div class="calendar-grid">${buildCalendar(calendar)}</div>
</section>
<!-- Size Chart --> <section class="detail-section">
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">Data Volume</h4> <h3>Datenvolumen</h3>
<div class="flex items-end gap-[2px] h-16 mb-6">${buildSizeChart(history)}</div> <div class="size-bars">${buildSizeChart(history)}</div>
</section>
<!-- History --> <section class="detail-section">
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">Recent Backups</h4> <h3>Letzte Sicherungen</h3>
<div class="space-y-0"> <div class="history-list">
${history.slice(0, 15).map(e => ` ${history.slice(0, 15).map(entry => `
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-surface-container transition-colors text-xs"> <div class="history-row">
<span class="font-mono text-on-surface-variant w-24 shrink-0">${new Date(e.timestamp).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}</span> <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="w-2 h-2 rounded-full ${e.status === 'ok' ? 'bg-secondary' : 'bg-error'} shrink-0"></span> <span class="status-dot status--${entry.status === 'ok' ? 'ok' : 'error'}"></span>
<span class="flex-1 font-medium">${fmtDuration(e.duration_sec)}</span> <span>${fmtDuration(entry.duration_sec)}</span>
<span class="text-on-surface-variant">${fmtBytes(e.original_size)}</span> <span class="history-size">${fmtBytes(entry.original_size)}</span>
<span class="text-on-surface-variant">${e.nfiles_new ? `+${e.nfiles_new}` : ''}</span> <span class="history-files">${entry.nfiles_new ? `+${entry.nfiles_new}` : ''}</span>
</div> </div>
`).join('')} `).join('') || '<p class="empty-state">Keine Einträge im Zeitraum.</p>'}
</div> </div>
</section>
<!-- Actions --> <div class="drawer-actions">
<div class="flex gap-3 mt-8 pt-6 border-t border-outline-variant/10"> <button type="button" class="button" onclick="openEditHost(document.getElementById('drawerTitle').textContent)">Bearbeiten</button>
<button onclick="openEditHost('${name}')" class="bg-surface-container-high hover:bg-surface-container-highest px-5 py-2.5 rounded-lg text-xs font-bold transition-all flex items-center gap-2"> <button type="button" class="button button--danger" onclick="confirmDelete(document.getElementById('drawerTitle').textContent)">Löschen</button>
<span class="material-symbols-outlined text-sm">settings</span> Edit </div>`;
</button> } catch (error) {
<button onclick="confirmDelete('${name}')" class="hover:bg-error/10 text-error px-5 py-2.5 rounded-lg text-xs font-bold transition-all flex items-center gap-2"> body.innerHTML = '<p class="empty-state">Die Host-Details konnten nicht geladen werden.</p>';
<span class="material-symbols-outlined text-sm">delete</span> Delete console.error('Host-Details konnten nicht geladen werden:', error);
</button> }
</div>
`;
} }
function buildCalendar(cal) { function buildCalendar(calendar) {
const days = []; const days = [];
for (let i = 29; i >= 0; i--) { for (let offset = 29; offset >= 0; offset -= 1) {
const d = new Date(); d.setDate(d.getDate() - i); const date = new Date();
const key = d.toISOString().split('T')[0]; date.setDate(date.getDate() - offset);
const data = cal[key]; const key = date.toISOString().split('T')[0];
const num = d.getDate(); const data = calendar[key];
if (!data) { days.push(`<div class="aspect-square rounded bg-surface-container flex items-center justify-center text-[10px] text-slate-600" title="${key}: No backup">${num}</div>`); } if (!data) {
else { days.push(`<div class="calendar-day" title="${key}: keine Sicherung">${date.getDate()}</div>`);
const cls = data.has_error ? 'bg-error/20 text-error' : 'bg-secondary/20 text-secondary'; } else {
days.push(`<div class="aspect-square rounded ${cls} flex items-center justify-center text-[10px] font-bold cursor-default" title="${key}: ${data.count}x, ${fmtBytes(data.total_size)}">${num}</div>`); 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(''); return days.join('');
@ -270,91 +325,149 @@ function buildCalendar(cal) {
function buildSizeChart(history) { function buildSizeChart(history) {
const byDay = {}; const byDay = {};
history.forEach(e => { const d = e.timestamp.split('T')[0]; byDay[d] = (byDay[d]||0) + e.original_size; }); history.forEach(entry => {
const day = entry.timestamp.split('T')[0];
byDay[day] = (byDay[day] || 0) + Number(entry.original_size || 0);
});
const days = []; 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}); } for (let offset = 29; offset >= 0; offset -= 1) {
const max = Math.max(...days.map(d=>d.size), 1); const date = new Date();
return days.map(d => { date.setDate(date.getDate() - offset);
const h = d.size ? Math.max(6, (d.size/max)*100) : 4; const key = date.toISOString().split('T')[0];
return `<div class="flex-1 rounded-t bg-primary ${d.size ? 'opacity-70 hover:opacity-100' : 'opacity-15'} transition-opacity" style="height:${h}%" title="${d.key}: ${fmtBytes(d.size)}"></div>`; 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(''); }).join('');
} }
function closeDrawer() { function closeDrawer() {
document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none'); const drawer = document.getElementById('drawer');
document.getElementById('drawer').classList.add('translate-x-full'); document.getElementById('drawerBg').classList.add('is-hidden');
drawer.classList.add('is-closed');
drawer.setAttribute('aria-hidden', 'true');
} }
// ── Modal ─────────────────────────────────────────────────
function openAddHost() { function openAddHost() {
document.getElementById('modalTitle').textContent = 'Add Host'; document.getElementById('modalTitle').textContent = 'Host hinzufügen';
document.getElementById('formMode').value = 'add'; document.getElementById('formMode').value = 'add';
document.getElementById('formName').value = ''; document.getElementById('formName').disabled = false; document.getElementById('formName').value = '';
document.getElementById('formName').disabled = false;
document.getElementById('formKumaUrl').value = ''; document.getElementById('formKumaUrl').value = '';
document.getElementById('formEnabled').checked = true; document.getElementById('formEnabled').checked = true;
openModal(); openModal();
} }
async function openEditHost(name) { function openEditHost(name) {
closeDrawer(); closeDrawer();
const h = allHosts.find(x => x.name === name); if (!h) return; const host = allHosts.find(item => item.name === name);
document.getElementById('modalTitle').textContent = `Edit: ${name}`; if (!host) return;
document.getElementById('modalTitle').textContent = `${name} bearbeiten`;
document.getElementById('formMode').value = 'edit'; document.getElementById('formMode').value = 'edit';
document.getElementById('formName').value = h.name; document.getElementById('formName').disabled = true; document.getElementById('formName').value = host.name;
document.getElementById('formKumaUrl').value = h.kuma_push_url || ''; document.getElementById('formName').disabled = true;
document.getElementById('formEnabled').checked = h.enabled; document.getElementById('formKumaUrl').value = host.kuma_push_url || '';
document.getElementById('formEnabled').checked = host.enabled;
openModal(); openModal();
} }
async function saveHost(e) { async function saveHost(event) {
e.preventDefault(); event.preventDefault();
const mode = document.getElementById('formMode').value; const mode = document.getElementById('formMode').value;
const name = document.getElementById('formName').value.trim(); const name = document.getElementById('formName').value.trim();
const kuma = document.getElementById('formKumaUrl').value.trim(); const kumaUrl = document.getElementById('formKumaUrl').value.trim();
const enabled = document.getElementById('formEnabled').checked; const enabled = document.getElementById('formEnabled').checked;
if (mode === 'add') { if (mode === 'add') {
await apiFetch(`${API}/api/hosts`, { method:'POST', headers:authHeaders(), body:JSON.stringify({name, kuma_push_url:kuma}) }); await apiFetch(`${API}/api/hosts`, {
toast(`${name} added`); method: 'POST', headers: authHeaders(), body: JSON.stringify({name, kuma_push_url: kumaUrl}),
});
toast(`${name} wurde hinzugefügt`);
} else { } else {
await apiFetch(`${API}/api/hosts/${name}`, { method:'PUT', headers:authHeaders(), body:JSON.stringify({kuma_push_url:kuma, enabled}) }); await apiFetch(`${API}/api/hosts/${encodeURIComponent(name)}`, {
toast(`${name} updated`); method: 'PUT', headers: authHeaders(), body: JSON.stringify({kuma_push_url: kumaUrl, enabled}),
});
toast(`${name} wurde aktualisiert`);
} }
closeModal(); loadAll(); closeModal();
loadAll();
} }
async function confirmDelete(name) { async function confirmDelete(name) {
if (!confirm(`Delete "${name}" and all history?`)) return; if (!confirm(`Host "${name}" einschließlich Historie löschen?`)) return;
await apiFetch(`${API}/api/hosts/${name}`, { method:'DELETE', headers:authHeaders() }); await apiFetch(`${API}/api/hosts/${encodeURIComponent(name)}`, {method: 'DELETE', headers: authHeaders()});
toast(`${name} deleted`); closeDrawer(); loadAll(); toast(`${name} wurde gelöscht`);
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 openModal() {
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'); } 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');
}
// ── Navigation ────────────────────────────────────────────
function showPage(page) { function showPage(page) {
currentPage = page; currentPage = page;
['dashboard','alerts','hosts','config'].forEach(p => { ['dashboard', 'alerts', 'hosts', 'config'].forEach(item => {
document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page); document.getElementById(`page-${item}`).classList.toggle('hidden', item !== page);
// Nav highlights const nav = document.getElementById(`nav-${item}`);
const nav = document.getElementById(`nav-${p}`); nav.classList.toggle('is-active', item === page);
const side = document.getElementById(`side-${p}`); if (item === page) nav.setAttribute('aria-current', 'page');
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'; } else nav.removeAttribute('aria-current');
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'; }
}); });
window.scrollTo({top: 0, behavior: 'smooth'});
} }
// ── Toast ───────────────────────────────────────────────── function toast(message) {
function toast(msg) { const element = document.createElement('div');
const t = document.createElement('div'); element.className = 'toast';
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]'; element.textContent = message;
t.innerHTML = `<span class="material-symbols-outlined text-secondary text-sm" style="font-variation-settings:'FILL' 1">check_circle</span> ${msg}`; document.getElementById('toasts').appendChild(element);
document.getElementById('toasts').appendChild(t); setTimeout(() => element.remove(), 4000);
setTimeout(() => t.remove(), 4000);
} }
// ── Helpers ─────────────────────────────────────────────── function fmtBytes(bytes) {
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]; } const value = Number(bytes || 0);
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'; } if (!value) return '0 B';
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'; } const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
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] || ''; } 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;');
}