-
${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(host => `
-
+ grid.innerHTML = allHosts.map(h => `
+
+
+
+ ${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`}
+
+
+
+
+
`).join('');
}
+// ── Host Detail Drawer ────────────────────────────────────
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');
+ document.getElementById('drawerBg').classList.remove('opacity-0','pointer-events-none');
+ document.getElementById('drawer').classList.remove('translate-x-full');
const body = document.getElementById('drawerBody');
- body.innerHTML = '
+
+ ${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)}
+ Details werden geladen …
'; + body.innerHTML = 'Loading...
';
- 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 [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) || {};
- body.innerHTML = `
-
-
-
-
-
-
-
-
-
-
- The Sentinel · Backup-Monitor
-
-
+
+
+The Sentinel – Backup Monitor
+
+
+
+
+
+
-
-
-
${history.length}Sicherungen
- ${successRate}%Erfolgreich
- ${fmtBytes(totalSize)}Volumen
+ 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}` : ''}
+ `).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(calendar) {
+function buildCalendar(cal) {
const days = [];
- for (let offset = 29; offset >= 0; offset -= 1) {
- const date = new Date();
- date.setDate(date.getDate() - offset);
- const key = date.toISOString().split('T')[0];
- const data = calendar[key];
- if (!data) {
- days.push(`${date.getDate()}
`);
- } else {
- const state = data.has_error ? 'has-error' : 'has-backup';
- days.push(`${date.getDate()}
`);
+ 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}
`);
}
}
return days.join('');
@@ -325,149 +270,91 @@ function buildCalendar(calendar) {
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);
- });
+ history.forEach(e => { const d = e.timestamp.split('T')[0]; byDay[d] = (byDay[d]||0) + e.original_size; });
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 ``;
+ 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 ``;
}).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');
+ document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none');
+ document.getElementById('drawer').classList.add('translate-x-full');
}
+// ── Modal ─────────────────────────────────────────────────
function openAddHost() {
- document.getElementById('modalTitle').textContent = 'Host hinzufügen';
+ document.getElementById('modalTitle').textContent = 'Add Host';
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();
}
-function openEditHost(name) {
+async function openEditHost(name) {
closeDrawer();
- const host = allHosts.find(item => item.name === name);
- if (!host) return;
- document.getElementById('modalTitle').textContent = `${name} bearbeiten`;
+ const h = allHosts.find(x => x.name === name); if (!h) return;
+ document.getElementById('modalTitle').textContent = `Edit: ${name}`;
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;
+ 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;
openModal();
}
-async function saveHost(event) {
- event.preventDefault();
+async function saveHost(e) {
+ e.preventDefault();
const mode = document.getElementById('formMode').value;
const name = document.getElementById('formName').value.trim();
- const kumaUrl = document.getElementById('formKumaUrl').value.trim();
+ const kuma = 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`);
+ await apiFetch(`${API}/api/hosts`, { method:'POST', headers:authHeaders(), body:JSON.stringify({name, kuma_push_url:kuma}) });
+ toast(`${name} added`);
} else {
- await apiFetch(`${API}/api/hosts/${encodeURIComponent(name)}`, {
- method: 'PUT', headers: authHeaders(), body: JSON.stringify({kuma_push_url: kumaUrl, enabled}),
- });
- toast(`${name} wurde aktualisiert`);
+ await apiFetch(`${API}/api/hosts/${name}`, { method:'PUT', headers:authHeaders(), body:JSON.stringify({kuma_push_url:kuma, enabled}) });
+ toast(`${name} updated`);
}
- closeModal();
- loadAll();
+ 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();
+ if (!confirm(`Delete "${name}" and all history?`)) return;
+ await apiFetch(`${API}/api/hosts/${name}`, { method:'DELETE', headers:authHeaders() });
+ toast(`${name} deleted`); 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 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'); }
+// ── Navigation ────────────────────────────────────────────
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');
+ ['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'; }
});
- 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);
+// ── 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 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, ''');
-}
+// ── 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] || ''; }
diff --git a/backup-monitor/templates/index.html b/backup-monitor/templates/index.html
index 8a8a2e4..6eb279d 100644
--- a/backup-monitor/templates/index.html
+++ b/backup-monitor/templates/index.html
@@ -1,201 +1,295 @@
-
-
+
+
-
-
-
-
-
- S
-
- The Sentinel
- Backup-Monitor
-
-
+
-
+
+
-
-
+
+
+
-
- Betriebsbereit
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- Hosts in Ordnung
- –
-
-
- Heute gesichert
- –
-
-
- Letzte Sicherung
- –
- –
-
-
- Auffälligkeiten
- 0
-
-
-
-
-
+
+ Vault Overview
+Operational command for Borgmatic backup infrastructure.
+
+
+ Last Scan:
+ –
+
+
-
- Borgmatic-Infrastruktur
-Backup-Übersicht
-Der aktuelle Stand aller gemeldeten Sicherungen.
-
- Letzte Aktualisierung
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
- Gesichertes Datenvolumen
-Summe der letzten 30 Tage, über die zehn zuerst gemeldeten Hosts.
-
-
-
- Host-Status
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+
-
- check_circle
Letzte Meldungen
- automatisch alle 30 Sekunden -
-
- Prüfbedarf
-Meldungen
-Fehlgeschlagene oder überfällige Sicherungen.
--
-
- Fehler
- 0
- Überfällig
- 0
-
+ Keine offenen Meldungen.
+–
+ Hosts OK
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
- database
-
-
- Inventar
-Backup-Hosts
-Alle registrierten Endpunkte und ihre letzten Sicherungen.
-
-
- Schnittstellen
-Einstellungen
-Die Endpunkte, über die Sentinel Daten annimmt und ausliefert.
--
-
- Backup-Meldungen -
POST /api/push
- - Prometheus -
GET /metrics
- - API-Schlüssel -
- {{ 'Aktiviert (Umgebungsvariable API_KEY)' if api_key_required else 'Deaktiviert' }} -
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Alert Center
+Real-time surveillance of the backup infrastructure.
+
+
+
+ 0
+ Errors
+
+
+ 0
+ Stale
+
+
+
+No active alerts – all systems operational ✓
+
+
+
+
+
+
+
+
+
+
+ Backup Hosts
+Manage and monitor all registered backup endpoints.
+
+
+
+
+
+
+
+
+
+
+
+
+
+Configuration
+API endpoint and integration settings.
+
+
+
+
+ Push Endpoint
+POST /api/push
+
+
+ Prometheus Metrics
+GET /metrics
+
+
+ API Key
+{{ 'Enabled – set via API_KEY env var' if api_key_required else 'Disabled – all endpoints open' }}
+
+
+
+
+
+
+
diff --git a/backup-monitor/tests/test_frontend_contract.py b/backup-monitor/tests/test_frontend_contract.py
deleted file mode 100644
index 72b9c5c..0000000
--- a/backup-monitor/tests/test_frontend_contract.py
+++ /dev/null
@@ -1,77 +0,0 @@
-import re
-import subprocess
-import unittest
-from pathlib import Path
-
-
-ROOT = Path(__file__).resolve().parents[1]
-HTML = (ROOT / "templates" / "index.html").read_text(encoding="utf-8")
-JS = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
-CSS = (ROOT / "static" / "css" / "style.css").read_text(encoding="utf-8")
-
-
-class FrontendContractTests(unittest.TestCase):
- def test_stable_dom_ids_exist_once(self):
- required = {
- "nav-dashboard", "nav-alerts", "nav-hosts", "nav-config",
- "sysStatus", "lastScan", "mOk", "mSize", "mLatest",
- "mLatestHost", "mWarn", "mWarnCard", "clusterList",
- "liveStream", "chartSvg", "hostGrid", "alertList",
- "drawer", "drawerBg", "drawerTitle", "drawerBody",
- "modal", "modalBg", "hostForm", "formMode", "formName",
- "formKumaUrl", "formEnabled", "toasts",
- }
- for element_id in required:
- count = len(re.findall(rf'id=["\']{re.escape(element_id)}["\']', HTML))
- self.assertEqual(count, 1, f"#{element_id} must exist exactly once")
-
- def test_uses_local_styles_without_runtime_css_framework(self):
- self.assertIn('/static/css/style.css', HTML)
- self.assertNotIn('cdn.tailwindcss.com', HTML)
- self.assertNotIn('Material+Symbols', HTML)
- self.assertNotIn('backdrop-blur', HTML)
- self.assertNotIn('pulse-', HTML)
-
- def test_monitor_surface_has_semantic_landmarks_and_accessible_actions(self):
- self.assertRegex(HTML, r'
+
+
+