diff --git a/static/css/style.css b/static/css/style.css index c1f5373..9452692 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,486 +1 @@ -/* ── 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; } -} +/* Sentinel Design System – all styles via Tailwind CSS */ diff --git a/static/js/app.js b/static/js/app.js index fec0435..73ccfd8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,13 +1,14 @@ -/* ── Backup Monitor – Frontend Logic ────────────────────────── */ +/* ── The Sentinel – Backup Monitor Frontend ────────────────── */ const API = ''; -let refreshTimer; let apiKey = localStorage.getItem('bm_api_key') || ''; +let allHosts = []; +let currentPage = 'dashboard'; -// ── Init ────────────────────────────────────────────────────── +// ── Init ────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { loadAll(); - refreshTimer = setInterval(loadAll, 30000); + setInterval(loadAll, 30000); }); function authHeaders() { @@ -22,232 +23,273 @@ async function apiFetch(url, opts = {}) { 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); - } + if (key) { apiKey = key; localStorage.setItem('bm_api_key', key); opts.headers['X-API-Key'] = key; return fetch(url, opts); } } return r; } async function loadAll() { - await Promise.all([loadSummary(), loadHosts()]); - document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE'); + 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'; } } -// ── 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); +// ── 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' : ''); - 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'); + // Latest backup + const sorted = [...allHosts].filter(h => h.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('mLatestHost').textContent = sorted[0].name; + } + + // 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; + + // Live stream + renderLiveStream(); + loadVolumeChart(); } -// ── 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 => ` -
-
- ${h.name} - ${statusLabel(h.status)} -
-
-
- Letztes Backup - ${h.last_backup ? timeAgo(h.last_backup) : 'Nie'} -
-
- 7-Tage - ${h.backup_count_7d} Backups -
-
- Ø Dauer - ${fmtDuration(h.avg_duration_7d)} -
-
- 7-Tage Volumen - ${fmtBytes(h.total_size_7d)} -
-
-
+function clusterGroup(label, hosts, color) { + return ` +
+
${label}
+ ${hosts.map(h => ` +
+
+
${h.name}
+
schedule ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}
+
+ ${h.status.toUpperCase()}
- `).join(''); - - // Load minibars - for (const h of hosts) { - loadMinibar(h.name); - } + `).join('')} +
`; } -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 `
`; - const h = Math.max(15, (d.data.total_size / maxSize) * 100); - const cls = d.data.has_error ? 'error' : 'ok'; - return `
`; +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'; + return ` +
+ ${t} +
+ ${isErr ? 'ERROR: ' : ''}Backup for ${h.name} ${isErr ? 'failed' : 'completed successfully'}. + ${h.last_message ? `${h.last_message}` : ''} +
`; }).join(''); } -// ── Host Detail Drawer ──────────────────────────────────────── +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); + + // 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`; + + document.getElementById('chartSvg').innerHTML = ` + + ${[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 al = document.getElementById('alertList'); + if (!issues.length) { al.innerHTML = '
No active alerts – all systems operational ✓
'; 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'; + 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`} +
+
+
+ +
+
+
`; + }).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)}
+
+
+ `).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'); - + 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 = '
Lade...
'; + body.innerHTML = '
Loading...
'; - 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 [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 hosts = await hostsR.json(); - const host = hosts.find(h => h.name === name) || {}; + const host = allHosts.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; + 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 (30d)
-
-
-
${successRate}%
-
Erfolgsrate
-
-
-
${fmtDuration(avgDuration)}
-
Ø Dauer
-
-
+ +
+
${history.length}
Backups
+
${rate}%
Success
+
${fmtDuration(avgDur)}
Avg Duration
+
-
Kalender (30 Tage)
-
${buildCalendar(calendar)}
+ +

30-Day Calendar

+
${buildCalendar(calendar)}
-
Datenvolumen (30 Tage)
-
${buildSizeChart(history)}
+ +

Data Volume

+
${buildSizeChart(history)}
-
Letzte Backups
- - - - ${history.slice(0, 20).map(e => ` - - - - - - - - `).join('')} - -
DatumStatusDauerGrößeDateien
${new Date(e.timestamp).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}${e.status}${fmtDuration(e.duration_sec)}${fmtBytes(e.original_size)}${e.nfiles_new ? `+${e.nfiles_new}` : '–'} ${e.nfiles_changed ? `/ ~${e.nfiles_changed}` : ''}
+ +

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('')} +
-
- - -
+ +
+ + +
`; } 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 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(`
${dayNum}
${key}
Kein Backup
`); - } else { - const cls = data.has_error ? 'error' : 'ok'; - days.push(`
${dayNum}
${key}
${data.count}x Backup
${fmtBytes(data.total_size)}
Ø ${fmtDuration(data.avg_duration)}
`); + 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}
`); } } - // Future days to fill the row - for (let i = 1; i <= 5; i++) { - const d = new Date(); - d.setDate(d.getDate() + i); - days.push(`
${d.getDate()}
`); - } 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; - }); - + history.forEach(e => { const d = e.timestamp.split('T')[0]; byDay[d] = (byDay[d]||0) + 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); + 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(4, (d.size / maxSize) * 100) : 2; - const opacity = d.size ? '' : 'opacity:0.2;'; - return `
${d.key}
${fmtBytes(d.size)}
`; + const h = d.size ? Math.max(6, (d.size/max)*100) : 4; + return `
`; }).join(''); } function closeDrawer() { - document.getElementById('drawerOverlay').classList.remove('open'); - document.getElementById('drawer').classList.remove('open'); + document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none'); + document.getElementById('drawer').classList.add('translate-x-full'); } -// ── Add/Edit Host Modal ─────────────────────────────────────── +// ── 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(); @@ -255,15 +297,10 @@ function openAddHost() { 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`; + 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 = h.name; - document.getElementById('formName').disabled = true; + 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(); @@ -275,77 +312,49 @@ async function saveHost(e) { const name = document.getElementById('formName').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: kuma }) - }); - toast(`${name} hinzugefügt`, 'success'); + 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/${name}`, { - method: 'PUT', - headers: authHeaders(), - body: JSON.stringify({ kuma_push_url: kuma, enabled }) - }); - toast(`${name} aktualisiert`, 'success'); + 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}" und alle History wirklich löschen?`)) return; - await apiFetch(`${API}/api/hosts/${name}`, { method: 'DELETE', headers: authHeaders() }); - toast(`${name} gelöscht`, 'success'); - 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() { - 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'); +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(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'; } + }); } -// ── Toast ───────────────────────────────────────────────────── -function toast(msg, type = 'success') { - const c = document.getElementById('toastContainer'); +// ── Toast ───────────────────────────────────────────────── +function toast(msg) { const t = document.createElement('div'); - t.className = `toast ${type}`; - t.innerHTML = `${type === 'success' ? '✓' : '✕'} ${msg}`; - c.appendChild(t); + 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); } -// ── 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; -} +// ── 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/templates/index.html b/templates/index.html index 91f2a71..6eb279d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,102 +1,295 @@ - + - - - Backup Monitor – Pfannkuchen - - + + +The Sentinel – Backup Monitor + + + + + + - - -
-
- -

Backup Monitor

- Homelab Pfannkuchen -
-
-
- - - -
-
+ - -
-
-
-
Hosts
-
-
-
-
OK
-
-
-
-
Überfällig
-
-
-
-
Fehler
-
-
-
-
Heute
-
-
-
-
Heute gesichert
-
-
- - -
- - -
- - - - -