diff --git a/backup-monitor/static/js/app.js b/backup-monitor/static/js/app.js index 73ccfd8..970cf5e 100644 --- a/backup-monitor/static/js/app.js +++ b/backup-monitor/static/js/app.js @@ -1,268 +1,323 @@ -/* ── The Sentinel – Backup Monitor Frontend ────────────────── */ +/* The Sentinel – Backup Monitor */ const API = ''; let apiKey = localStorage.getItem('bm_api_key') || ''; let allHosts = []; let currentPage = 'dashboard'; -// ── Init ────────────────────────────────────────────────── +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 h = {'Content-Type': 'application/json'}; - if (apiKey) h['X-API-Key'] = apiKey; - return h; + const headers = {'Content-Type': 'application/json'}; + if (apiKey) headers['X-API-Key'] = apiKey; + return headers; } -async function apiFetch(url, opts = {}) { - if (!opts.headers) opts.headers = {}; - if (apiKey) opts.headers['X-API-Key'] = apiKey; - const r = await fetch(url, opts); - if (r.status === 401) { - const key = prompt('🔑 API-Key eingeben:'); - if (key) { apiKey = key; localStorage.setItem('bm_api_key', key); opts.headers['X-API-Key'] = key; return fetch(url, opts); } +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 r; + return response; } async function loadAll() { - const [sumR, hostsR] = await Promise.all([fetch(`${API}/api/summary`), fetch(`${API}/api/hosts`)]); - const sum = await sumR.json(); - allHosts = await hostsR.json(); - renderDashboard(sum); - renderAlerts(); - renderHostGrid(); - document.getElementById('lastScan').textContent = new Date().toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'}); - // System status indicator - const ss = document.getElementById('sysStatus'); - if (sum.error > 0) { ss.innerHTML = 'errorErrors Active'; } - else if (sum.stale > 0) { ss.innerHTML = 'warningStale Hosts'; } - else { ss.innerHTML = 'cloud_doneAll Systems OK'; } + 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); + } } -// ── Dashboard ───────────────────────────────────────────── -function renderDashboard(sum) { - document.getElementById('mOk').textContent = `${sum.ok}/${sum.total_hosts}`; - document.getElementById('mSize').textContent = fmtBytes(sum.today_size); - document.getElementById('mWarn').textContent = sum.error + sum.stale; - const wc = document.getElementById('mWarnCard'); - wc.className = 'bg-surface-container-low p-6 rounded-xl' + ((sum.error + sum.stale > 0) ? ' border border-error/20' : ''); +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'; + } +} - // Latest backup - const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup)); +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' ? 'Success' : 'Error'; + 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'; } - // Cluster list - const cl = document.getElementById('clusterList'); - const groups = { ok: [], stale: [], error: [], disabled: [] }; - allHosts.forEach(h => groups[h.status]?.push(h)); - let html = ''; - if (groups.error.length) { html += clusterGroup('ERRORS', groups.error, 'error'); } - if (groups.stale.length) { html += clusterGroup('STALE', groups.stale, 'tertiary'); } - if (groups.ok.length) { html += clusterGroup('OPERATIONAL', groups.ok, 'secondary'); } - if (groups.disabled.length) { html += clusterGroup('DISABLED', groups.disabled, 'outline'); } - cl.innerHTML = html; + 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(''); - // Live stream renderLiveStream(); loadVolumeChart(); } -function clusterGroup(label, hosts, color) { +function clusterGroup(label, hosts, status) { return ` -
-
${label}
- ${hosts.map(h => ` -
-
-
${h.name}
-
schedule ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}
-
- ${h.status.toUpperCase()} -
- `).join('')} -
`; +
+
${label} · ${hosts.length}
+ ${hosts.map(host => ` + + `).join('')} +
`; } 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 ls = document.getElementById('liveStream'); - ls.innerHTML = sorted.map(h => { - const t = new Date(h.last_backup).toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit',second:'2-digit'}); - const isErr = h.last_status !== 'ok'; + 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 ` -
- ${t} -
- ${isErr ? 'ERROR: ' : ''}Backup for ${h.name} ${isErr ? 'failed' : 'completed successfully'}. - ${h.last_message ? `${h.last_message}` : ''} -
`; +
+ + + ${escapeHtml(host.name)} ${failed ? 'hat einen Fehler gemeldet' : 'wurde erfolgreich gesichert'} + ${escapeHtml(host.last_message || '')} +
`; }).join(''); } async function loadVolumeChart() { - // Aggregate daily totals from all hosts 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]); } - const dailyTotals = {}; - days.forEach(d => dailyTotals[d] = 0); + 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])); - // Fetch calendar for top hosts (limit to avoid too many requests) - 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()))); - cals.forEach(cal => { Object.entries(cal).forEach(([day, data]) => { if (dailyTotals[day] !== undefined) dailyTotals[day] += data.total_size; }); }); - - const values = days.map(d => dailyTotals[d]); - const max = Math.max(...values, 1); - const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${100 - (v / max) * 80}`); - const pathD = 'M' + points.join(' L'); - const fillD = pathD + ` L100,100 L0,100 Z`; + 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('')} - - + + + + + + + ${[20, 40, 60, 80].map(y => ``).join('')} + + `; - document.getElementById('chartSvg').setAttribute('viewBox', '0 0 100 100'); - document.getElementById('chartSvg').setAttribute('preserveAspectRatio', 'none'); } -// ── Alerts ──────────────────────────────────────────────── function renderAlerts() { - const issues = allHosts.filter(h => h.status === 'error' || h.status === 'stale'); - document.getElementById('aCrit').textContent = String(allHosts.filter(h => h.status === 'error').length).padStart(2, '0'); - document.getElementById('aStale').textContent = String(allHosts.filter(h => h.status === 'stale').length).padStart(2, '0'); + 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 al = document.getElementById('alertList'); - if (!issues.length) { al.innerHTML = '
No active alerts – all systems operational ✓
'; return; } + const list = document.getElementById('alertList'); + if (!issues.length) { + list.innerHTML = '

Keine offenen Meldungen. Alle aktiven Hosts sind im Zeitfenster.

'; + return; + } - al.innerHTML = issues.map(h => { - const isCrit = h.status === 'error'; - const color = isCrit ? 'error' : 'tertiary'; - const icon = isCrit ? 'error' : 'warning'; - const label = isCrit ? 'CRITICAL' : 'STALE'; + list.innerHTML = issues.map(host => { + const failed = host.status === 'error'; return ` -
-
-
- ${icon} -
-
-
-

${isCrit ? 'Backup Failed' : 'Backup Overdue'} – ${h.name}

- ${label} -
-
- dns ${h.name} - schedule ${h.last_backup ? timeAgo(h.last_backup) : 'Never'} - ${h.last_message ? `${h.last_message}` : `${Math.round(h.age_hours)}h without backup`} +
+ +
+

${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(''); } -// ── Host Grid ───────────────────────────────────────────── function renderHostGrid() { const grid = document.getElementById('hostGrid'); - grid.innerHTML = allHosts.map(h => ` -
-
-
-
${h.name}
- ${h.status.toUpperCase()} -
-
-
Last Backup${h.last_backup ? timeAgo(h.last_backup) : 'Never'}
-
7d Backups${h.backup_count_7d}
-
Avg Duration${fmtDuration(h.avg_duration_7d)}
-
7d Volume${fmtBytes(h.total_size_7d)}
-
-
+ grid.innerHTML = allHosts.map(host => ` + `).join(''); } -// ── Host Detail Drawer ──────────────────────────────────── async function openHost(name) { + const drawer = document.getElementById('drawer'); document.getElementById('drawerTitle').textContent = name; - document.getElementById('drawerBg').classList.remove('opacity-0','pointer-events-none'); - document.getElementById('drawer').classList.remove('translate-x-full'); + document.getElementById('drawerBg').classList.remove('is-hidden'); + drawer.classList.remove('is-closed'); + drawer.setAttribute('aria-hidden', 'false'); const body = document.getElementById('drawerBody'); - body.innerHTML = '
Loading...
'; + body.innerHTML = '

Details werden geladen …

'; - const [histR, calR] = await Promise.all([fetch(`${API}/api/history/${name}?days=30`), fetch(`${API}/api/calendar/${name}?days=30`)]); - const history = await histR.json(); - const calendar = await calR.json(); - const host = allHosts.find(h => h.name === name) || {}; + 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; - const totalSize = history.reduce((s,e) => s + e.original_size, 0); - const avgDur = history.length ? Math.round(history.reduce((s,e) => s + e.duration_sec, 0) / history.length) : 0; - const rate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0; - - body.innerHTML = ` - -
-
${history.length}
Backups
-
${rate}%
Success
-
${fmtDuration(avgDur)}
Avg Duration
-
- - -

30-Day Calendar

-
${buildCalendar(calendar)}
- - -

Data Volume

-
${buildSizeChart(history)}
- - -

Recent Backups

-
- ${history.slice(0, 15).map(e => ` -
- ${new Date(e.timestamp).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})} - - ${fmtDuration(e.duration_sec)} - ${fmtBytes(e.original_size)} - ${e.nfiles_new ? `+${e.nfiles_new}` : ''} + body.innerHTML = ` +
+
${history.length}Sicherungen
+
${successRate}%Erfolgreich
+
${fmtBytes(totalSize)}Volumen
- `).join('')} -
- -
- - -
- `; +
+

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(cal) { +function buildCalendar(calendar) { const days = []; - for (let i = 29; i >= 0; i--) { - const d = new Date(); d.setDate(d.getDate() - i); - const key = d.toISOString().split('T')[0]; - const data = cal[key]; - const num = d.getDate(); - if (!data) { days.push(`
${num}
`); } - else { - const cls = data.has_error ? 'bg-error/20 text-error' : 'bg-secondary/20 text-secondary'; - days.push(`
${num}
`); + 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(''); @@ -270,91 +325,149 @@ function buildCalendar(cal) { function buildSizeChart(history) { 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 = []; - 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}); } - const max = Math.max(...days.map(d=>d.size), 1); - return days.map(d => { - const h = d.size ? Math.max(6, (d.size/max)*100) : 4; - return `
`; + 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() { - document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none'); - document.getElementById('drawer').classList.add('translate-x-full'); + const drawer = document.getElementById('drawer'); + document.getElementById('drawerBg').classList.add('is-hidden'); + drawer.classList.add('is-closed'); + drawer.setAttribute('aria-hidden', 'true'); } -// ── Modal ───────────────────────────────────────────────── function openAddHost() { - document.getElementById('modalTitle').textContent = 'Add Host'; + document.getElementById('modalTitle').textContent = 'Host hinzufügen'; 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('formEnabled').checked = true; openModal(); } -async function openEditHost(name) { +function openEditHost(name) { closeDrawer(); - const h = allHosts.find(x => x.name === name); if (!h) return; - document.getElementById('modalTitle').textContent = `Edit: ${name}`; + 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 = h.name; document.getElementById('formName').disabled = true; - document.getElementById('formKumaUrl').value = h.kuma_push_url || ''; - document.getElementById('formEnabled').checked = h.enabled; + 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(e) { - e.preventDefault(); +async function saveHost(event) { + event.preventDefault(); const mode = document.getElementById('formMode').value; 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; if (mode === 'add') { - await apiFetch(`${API}/api/hosts`, { method:'POST', headers:authHeaders(), body:JSON.stringify({name, kuma_push_url:kuma}) }); - toast(`${name} added`); + 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/${name}`, { method:'PUT', headers:authHeaders(), body:JSON.stringify({kuma_push_url:kuma, enabled}) }); - toast(`${name} updated`); + 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(); + closeModal(); + loadAll(); } async function confirmDelete(name) { - if (!confirm(`Delete "${name}" and all history?`)) return; - await apiFetch(`${API}/api/hosts/${name}`, { method:'DELETE', headers:authHeaders() }); - toast(`${name} deleted`); closeDrawer(); loadAll(); + 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() { 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 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'); } +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'); +} -// ── Navigation ──────────────────────────────────────────── function showPage(page) { currentPage = page; - ['dashboard','alerts','hosts','config'].forEach(p => { - document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page); - // Nav highlights - const nav = document.getElementById(`nav-${p}`); - const side = document.getElementById(`side-${p}`); - 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'; } - 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'; } + ['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'}); } -// ── Toast ───────────────────────────────────────────────── -function toast(msg) { - const t = document.createElement('div'); - 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]'; - t.innerHTML = `check_circle ${msg}`; - document.getElementById('toasts').appendChild(t); - setTimeout(() => t.remove(), 4000); +function toast(message) { + const element = document.createElement('div'); + element.className = 'toast'; + element.textContent = message; + document.getElementById('toasts').appendChild(element); + setTimeout(() => element.remove(), 4000); } -// ── Helpers ─────────────────────────────────────────────── -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]; } -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'; } -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'; } -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] || ''; } +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, '''); +}