Initial release – Backup Monitor with MongoDB, Dark Theme UI, Borgmatic + Uptime Kuma integration

This commit is contained in:
sascha 2026-04-05 08:58:18 +02:00
commit e2023abee5
10 changed files with 1378 additions and 0 deletions

486
static/css/style.css Normal file
View file

@ -0,0 +1,486 @@
/* ── Backup Monitor Dark Theme UI ─────────────────────────── */
:root {
--bg: #0f1117;
--bg-card: #1a1d27;
--bg-card-hover: #22263a;
--bg-drawer: #161922;
--border: #2a2e3d;
--text: #e4e6f0;
--text-dim: #8b8fa3;
--text-muted: #5c6078;
--accent: #6c5ce7;
--accent-glow: rgba(108, 92, 231, 0.3);
--ok: #00d68f;
--ok-bg: rgba(0, 214, 143, 0.1);
--ok-glow: rgba(0, 214, 143, 0.25);
--warn: #ffaa00;
--warn-bg: rgba(255, 170, 0, 0.1);
--warn-glow: rgba(255, 170, 0, 0.25);
--error: #ff3d71;
--error-bg: rgba(255, 61, 113, 0.1);
--error-glow: rgba(255, 61, 113, 0.25);
--disabled: #4a4e69;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 4px 24px rgba(0,0,0,0.3);
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* ── Header ────────────────────────────────────────────────── */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.logo { font-size: 28px; }
h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
.subtitle { color: var(--text-dim); font-size: 13px; }
.header-right { display: flex; align-items: center; gap: 12px; }
.meta-text { color: var(--text-muted); font-size: 12px; }
.pulse-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 8px var(--ok-glow);
animation: pulse 2s infinite;
}
.pulse-dot.warn { background: var(--warn); box-shadow: 0 0 8px var(--warn-glow); }
.pulse-dot.error { background: var(--error); box-shadow: 0 0 8px var(--error-glow); }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.85); }
}
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all var(--transition);
}
.btn:hover { background: var(--bg-card-hover); border-color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: #7c6cf0; box-shadow: 0 0 20px var(--accent-glow); }
.btn-icon { padding: 8px 10px; font-size: 16px; line-height: 1; }
.btn-danger { color: var(--error); }
.btn-danger:hover { background: var(--error-bg); border-color: var(--error); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* ── Summary Grid ──────────────────────────────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
padding: 24px;
}
.summary-card {
text-align: center;
padding: 20px 16px;
position: relative;
overflow: hidden;
}
.summary-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: var(--border);
border-radius: var(--radius) var(--radius) 0 0;
}
.card-ok::before { background: var(--ok); box-shadow: 0 0 12px var(--ok-glow); }
.card-warn::before { background: var(--warn); box-shadow: 0 0 12px var(--warn-glow); }
.card-error::before { background: var(--error); box-shadow: 0 0 12px var(--error-glow); }
.card-value { font-size: 32px; font-weight: 800; letter-spacing: -1px; }
.card-ok .card-value { color: var(--ok); }
.card-warn .card-value { color: var(--warn); }
.card-error .card-value { color: var(--error); }
.card-label { color: var(--text-dim); font-size: 12px; margin-top: 4px; text-transform: uppercase; letter-spacing: 1px; }
/* ── Cards ─────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all var(--transition);
}
.card:hover { border-color: var(--accent); box-shadow: var(--shadow); }
/* ── Host Grid ─────────────────────────────────────────────── */
.host-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 0 24px 24px;
}
.host-card {
cursor: pointer;
padding: 20px;
position: relative;
overflow: hidden;
}
.host-card::after {
content: '';
position: absolute;
top: 0; bottom: 0; left: 0;
width: 4px;
border-radius: var(--radius) 0 0 var(--radius);
}
.host-card[data-status="ok"]::after { background: var(--ok); box-shadow: 0 0 8px var(--ok-glow); }
.host-card[data-status="stale"]::after { background: var(--warn); box-shadow: 0 0 8px var(--warn-glow); }
.host-card[data-status="error"]::after { background: var(--error); box-shadow: 0 0 8px var(--error-glow); }
.host-card[data-status="disabled"]::after { background: var(--disabled); }
.host-card[data-status="disabled"] { opacity: 0.5; }
.host-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.host-name { font-size: 16px; font-weight: 700; letter-spacing: -0.3px; }
.host-badge {
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-ok { background: var(--ok-bg); color: var(--ok); }
.badge-stale { background: var(--warn-bg); color: var(--warn); }
.badge-error { background: var(--error-bg); color: var(--error); }
.badge-disabled { background: rgba(74,78,105,0.2); color: var(--disabled); }
.host-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.meta-item { display: flex; flex-direction: column; }
.meta-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.meta-value { font-size: 14px; font-weight: 600; margin-top: 2px; }
.host-minibar {
display: flex;
gap: 2px;
margin-top: 14px;
height: 24px;
align-items: flex-end;
}
.minibar-day {
flex: 1;
border-radius: 2px;
min-height: 3px;
transition: all var(--transition);
position: relative;
}
.minibar-day:hover { opacity: 0.8; transform: scaleY(1.15); }
.minibar-day.ok { background: var(--ok); }
.minibar-day.error { background: var(--error); }
.minibar-day.empty { background: var(--border); min-height: 3px; }
/* ── Drawer ────────────────────────────────────────────────── */
.drawer-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.drawer-overlay.open { opacity: 1; pointer-events: all; }
.drawer {
position: fixed;
top: 0; right: -520px; bottom: 0;
width: 500px;
max-width: 90vw;
background: var(--bg-drawer);
border-left: 1px solid var(--border);
z-index: 201;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: -8px 0 40px rgba(0,0,0,0.4);
}
.drawer.open { right: 0; }
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.drawer-header h2 { font-size: 18px; font-weight: 700; }
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ── Drawer: Calendar Heatmap ──────────────────────────────── */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin: 16px 0;
}
.cal-day {
aspect-ratio: 1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--text-muted);
cursor: default;
transition: all var(--transition);
position: relative;
}
.cal-day:hover { transform: scale(1.15); z-index: 1; }
.cal-day.empty { background: var(--bg-card); }
.cal-day.ok { background: var(--ok); color: #fff; font-weight: 700; }
.cal-day.error { background: var(--error); color: #fff; font-weight: 700; }
.cal-day.future { background: transparent; border: 1px dashed var(--border); }
.cal-day .tooltip {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 11px;
white-space: nowrap;
z-index: 10;
box-shadow: var(--shadow);
color: var(--text);
}
.cal-day:hover .tooltip { display: block; }
/* ── Drawer: History Table ─────────────────────────────────── */
.history-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-top: 16px;
}
.history-table th {
text-align: left;
padding: 8px 12px;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
}
.history-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.history-table tr:hover td { background: var(--bg-card-hover); }
/* ── Drawer: Stats Row ─────────────────────────────────────── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0;
}
.stat-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px;
text-align: center;
}
.stat-value { font-size: 22px; font-weight: 800; color: var(--accent); }
.stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
/* ── Drawer: Size Chart ────────────────────────────────────── */
.size-chart {
display: flex;
align-items: flex-end;
gap: 3px;
height: 80px;
margin: 16px 0;
padding: 0 2px;
}
.size-bar {
flex: 1;
background: var(--accent);
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: all var(--transition);
position: relative;
opacity: 0.7;
}
.size-bar:hover { opacity: 1; }
.size-bar .tooltip {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 11px;
white-space: nowrap;
z-index: 10;
box-shadow: var(--shadow);
color: var(--text);
}
.size-bar:hover .tooltip { display: block; }
/* ── Modal ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 300;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.modal-overlay.open { opacity: 1; pointer-events: all; }
.modal {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(0.95);
background: var(--bg-drawer);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0;
width: 440px;
max-width: 90vw;
z-index: 301;
opacity: 0;
pointer-events: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal.open { opacity: 1; pointer-events: all; transform: translate(-50%, -50%) scale(1); }
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { font-size: 16px; font-weight: 700; }
.modal form { padding: 24px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
.form-group input[type="text"],
.form-group input[type="url"] {
width: 100%;
padding: 10px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
transition: border-color var(--transition);
}
.form-group input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.checkbox-label { display: flex !important; align-items: center; gap: 8px; cursor: pointer; font-size: 14px !important; text-transform: none !important; letter-spacing: 0 !important; color: var(--text) !important; }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
/* ── Toast ─────────────────────────────────────────────────── */
.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 400; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 12px 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
box-shadow: var(--shadow);
animation: slideIn 0.3s ease-out;
display: flex;
align-items: center;
gap: 8px;
}
.toast.success { border-left: 3px solid var(--ok); }
.toast.error { border-left: 3px solid var(--error); }
@keyframes slideIn {
from { transform: translateX(100px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ── Section Headers ───────────────────────────────────────── */
.section-header {
font-size: 13px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
/* ── Drawer Actions ────────────────────────────────────────── */
.drawer-actions {
display: flex;
gap: 8px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 768px) {
header { flex-direction: column; gap: 12px; }
.summary-grid { grid-template-columns: repeat(3, 1fr); padding: 16px; }
.host-grid { grid-template-columns: 1fr; padding: 0 16px 16px; }
.drawer { width: 100%; max-width: 100vw; }
.stats-row { grid-template-columns: 1fr; }
}

328
static/js/app.js Normal file
View file

@ -0,0 +1,328 @@
/* ── Backup Monitor Frontend Logic ────────────────────────── */
const API = '';
let refreshTimer;
// ── Init ──────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadAll();
refreshTimer = setInterval(loadAll, 30000);
});
async function loadAll() {
await Promise.all([loadSummary(), loadHosts()]);
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE');
}
// ── Summary ───────────────────────────────────────────────────
async function loadSummary() {
const r = await fetch(`${API}/api/summary`);
const d = await r.json();
document.getElementById('valTotal').textContent = d.total_hosts;
document.getElementById('valOk').textContent = d.ok;
document.getElementById('valStale').textContent = d.stale;
document.getElementById('valError').textContent = d.error;
document.getElementById('valToday').textContent = d.today_backups;
document.getElementById('valSize').textContent = fmtBytes(d.today_size);
const pulse = document.getElementById('globalPulse');
pulse.className = 'pulse-dot';
if (d.error > 0) pulse.classList.add('error');
else if (d.stale > 0) pulse.classList.add('warn');
}
// ── Host Grid ─────────────────────────────────────────────────
async function loadHosts() {
const r = await fetch(`${API}/api/hosts`);
const hosts = await r.json();
const grid = document.getElementById('hostGrid');
grid.innerHTML = hosts.map(h => `
<div class="card host-card" data-status="${h.status}" onclick="openHost('${h.name}')">
<div class="host-header">
<span class="host-name">${h.name}</span>
<span class="host-badge badge-${h.status}">${statusLabel(h.status)}</span>
</div>
<div class="host-meta">
<div class="meta-item">
<span class="meta-label">Letztes Backup</span>
<span class="meta-value">${h.last_backup ? timeAgo(h.last_backup) : 'Nie'}</span>
</div>
<div class="meta-item">
<span class="meta-label">7-Tage</span>
<span class="meta-value">${h.backup_count_7d} Backups</span>
</div>
<div class="meta-item">
<span class="meta-label">Ø Dauer</span>
<span class="meta-value">${fmtDuration(h.avg_duration_7d)}</span>
</div>
<div class="meta-item">
<span class="meta-label">7-Tage Volumen</span>
<span class="meta-value">${fmtBytes(h.total_size_7d)}</span>
</div>
</div>
<div class="host-minibar" id="mini-${h.name}"></div>
</div>
`).join('');
// Load minibars
for (const h of hosts) {
loadMinibar(h.name);
}
}
async function loadMinibar(host) {
const r = await fetch(`${API}/api/calendar/${host}?days=14`);
const cal = await r.json();
const el = document.getElementById(`mini-${host}`);
if (!el) return;
const days = [];
for (let i = 13; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const key = d.toISOString().split('T')[0];
days.push({ key, data: cal[key] || null });
}
const maxSize = Math.max(...days.map(d => d.data?.total_size || 0), 1);
el.innerHTML = days.map(d => {
if (!d.data) return `<div class="minibar-day empty" title="${d.key}: Kein Backup"></div>`;
const h = Math.max(15, (d.data.total_size / maxSize) * 100);
const cls = d.data.has_error ? 'error' : 'ok';
return `<div class="minibar-day ${cls}" style="height:${h}%" title="${d.key}: ${d.data.count}x, ${fmtBytes(d.data.total_size)}"></div>`;
}).join('');
}
// ── Host Detail Drawer ────────────────────────────────────────
async function openHost(name) {
document.getElementById('drawerTitle').textContent = name;
document.getElementById('drawerOverlay').classList.add('open');
document.getElementById('drawer').classList.add('open');
const body = document.getElementById('drawerBody');
body.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted)">Lade...</div>';
const [histR, calR, hostsR] = await Promise.all([
fetch(`${API}/api/history/${name}?days=30`),
fetch(`${API}/api/calendar/${name}?days=30`),
fetch(`${API}/api/hosts`),
]);
const history = await histR.json();
const calendar = await calR.json();
const hosts = await hostsR.json();
const host = hosts.find(h => h.name === name) || {};
// Stats
const totalSize = history.reduce((s, e) => s + e.original_size, 0);
const avgDuration = history.length ? Math.round(history.reduce((s, e) => s + e.duration_sec, 0) / history.length) : 0;
const successRate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0;
body.innerHTML = `
<div class="stats-row">
<div class="stat-box">
<div class="stat-value">${history.length}</div>
<div class="stat-label">Backups (30d)</div>
</div>
<div class="stat-box">
<div class="stat-value">${successRate}%</div>
<div class="stat-label">Erfolgsrate</div>
</div>
<div class="stat-box">
<div class="stat-value">${fmtDuration(avgDuration)}</div>
<div class="stat-label">Ø Dauer</div>
</div>
</div>
<div class="section-header">Kalender (30 Tage)</div>
<div class="calendar-grid">${buildCalendar(calendar)}</div>
<div class="section-header">Datenvolumen (30 Tage)</div>
<div class="size-chart">${buildSizeChart(history)}</div>
<div class="section-header">Letzte Backups</div>
<table class="history-table">
<thead><tr><th>Datum</th><th>Status</th><th>Dauer</th><th>Größe</th><th>Dateien</th></tr></thead>
<tbody>
${history.slice(0, 20).map(e => `
<tr>
<td>${new Date(e.timestamp).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}</td>
<td><span class="host-badge badge-${e.status === 'ok' ? 'ok' : 'error'}">${e.status}</span></td>
<td>${fmtDuration(e.duration_sec)}</td>
<td>${fmtBytes(e.original_size)}</td>
<td>${e.nfiles_new ? `+${e.nfiles_new}` : ''} ${e.nfiles_changed ? `/ ~${e.nfiles_changed}` : ''}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="drawer-actions">
<button class="btn" onclick="openEditHost('${name}')"> Bearbeiten</button>
<button class="btn btn-danger" onclick="confirmDelete('${name}')">🗑 Löschen</button>
</div>
`;
}
function buildCalendar(cal) {
const days = [];
const now = new Date();
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 dayNum = d.getDate();
if (!data) {
days.push(`<div class="cal-day empty">${dayNum}<div class="tooltip">${key}<br>Kein Backup</div></div>`);
} else {
const cls = data.has_error ? 'error' : 'ok';
days.push(`<div class="cal-day ${cls}">${dayNum}<div class="tooltip">${key}<br>${data.count}x Backup<br>${fmtBytes(data.total_size)}<br>Ø ${fmtDuration(data.avg_duration)}</div></div>`);
}
}
// Future days to fill the row
for (let i = 1; i <= 5; i++) {
const d = new Date();
d.setDate(d.getDate() + i);
days.push(`<div class="cal-day future">${d.getDate()}</div>`);
}
return days.join('');
}
function buildSizeChart(history) {
// Group by day, last 30 days
const byDay = {};
history.forEach(e => {
const day = e.timestamp.split('T')[0];
if (!byDay[day]) byDay[day] = 0;
byDay[day] += e.original_size;
});
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];
days.push({ key, size: byDay[key] || 0 });
}
const maxSize = Math.max(...days.map(d => d.size), 1);
return days.map(d => {
const h = d.size ? Math.max(4, (d.size / maxSize) * 100) : 2;
const opacity = d.size ? '' : 'opacity:0.2;';
return `<div class="size-bar" style="height:${h}%;${opacity}"><div class="tooltip">${d.key}<br>${fmtBytes(d.size)}</div></div>`;
}).join('');
}
function closeDrawer() {
document.getElementById('drawerOverlay').classList.remove('open');
document.getElementById('drawer').classList.remove('open');
}
// ── Add/Edit Host Modal ───────────────────────────────────────
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();
}
async function openEditHost(name) {
closeDrawer();
const r = await fetch(`${API}/api/hosts`);
const hosts = await r.json();
const h = hosts.find(x => x.name === name);
if (!h) 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;
openModal();
}
async function saveHost(e) {
e.preventDefault();
const mode = document.getElementById('formMode').value;
const name = document.getElementById('formName').value.trim();
const kuma = document.getElementById('formKumaUrl').value.trim();
const enabled = document.getElementById('formEnabled').checked;
if (mode === 'add') {
await fetch(`${API}/api/hosts`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name, kuma_push_url: kuma })
});
toast(`${name} hinzugefügt`, 'success');
} else {
await fetch(`${API}/api/hosts/${name}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ kuma_push_url: kuma, enabled })
});
toast(`${name} aktualisiert`, 'success');
}
closeModal();
loadAll();
}
async function confirmDelete(name) {
if (!confirm(`Host "${name}" und alle History wirklich löschen?`)) return;
await fetch(`${API}/api/hosts/${name}`, { method: 'DELETE' });
toast(`${name} gelöscht`, 'success');
closeDrawer();
loadAll();
}
function openModal() {
document.getElementById('modalOverlay').classList.add('open');
document.getElementById('modal').classList.add('open');
}
function closeModal() {
document.getElementById('modalOverlay').classList.remove('open');
document.getElementById('modal').classList.remove('open');
}
// ── Toast ─────────────────────────────────────────────────────
function toast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `${type === 'success' ? '✓' : '✕'} ${msg}`;
c.appendChild(t);
setTimeout(() => t.remove(), 4000);
}
// ── Helpers ───────────────────────────────────────────────────
function fmtBytes(b) {
if (!b || b === 0) return '0 B';
const units = ['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) + ' ' + units[i];
}
function fmtDuration(sec) {
if (!sec || sec === 0) return '';
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec/60)}m ${sec%60}s`;
return `${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
function timeAgo(iso) {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff/60)}m`;
if (diff < 86400) return `vor ${Math.floor(diff/3600)}h`;
return `vor ${Math.floor(diff/86400)}d`;
}
function statusLabel(s) {
return { ok: 'OK', stale: 'Überfällig', error: 'Fehler', disabled: 'Deaktiviert' }[s] || s;
}