diff --git a/backup-monitor/static/css/style.css b/backup-monitor/static/css/style.css index 9452692..d0afffd 100644 --- a/backup-monitor/static/css/style.css +++ b/backup-monitor/static/css/style.css @@ -1 +1,721 @@ -/* Sentinel Design System – all styles via Tailwind CSS */ +:root { + color-scheme: dark; + --canvas: #101210; + --surface: #171a17; + --surface-raised: #1d211d; + --surface-hover: #232823; + --border: #2c312c; + --border-strong: #3a413a; + --text: #edf1eb; + --text-soft: #b8c0b7; + --text-muted: #7f897f; + --accent: #7abf8a; + --accent-soft: #203326; + --status-ok: #7abf8a; + --status-warning: #d0a15e; + --status-error: #d67b76; + --status-disabled: #7f897f; + --font-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --radius: 6px; + --max-width: 1280px; +} + +* { box-sizing: border-box; } + +html { min-width: 320px; background: var(--canvas); } + +body { + margin: 0; + min-height: 100vh; + background: var(--canvas); + color: var(--text); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +button, +input { font: inherit; } + +button, +a { -webkit-tap-highlight-color: transparent; } + +button { color: inherit; } + +a { color: inherit; } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.hidden { display: none !important; } + +.site-header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 1px solid var(--border); + background: rgba(16, 18, 16, 0.96); +} + +.topbar { + width: min(100% - 40px, var(--max-width)); + min-height: 64px; + margin: 0 auto; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 28px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + text-decoration: none; + white-space: nowrap; +} + +.brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border: 1px solid var(--border-strong); + border-radius: 5px; + background: var(--surface-raised); + color: var(--accent); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; +} + +.brand strong, +.brand small { display: block; } + +.brand strong { + font-size: 14px; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.brand small { + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; +} + +.main-nav { + display: flex; + align-items: stretch; + align-self: stretch; + gap: 2px; +} + +.main-nav a { + position: relative; + display: inline-flex; + align-items: center; + padding: 0 12px; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + text-decoration: none; +} + +.main-nav a::after { + content: ""; + position: absolute; + right: 12px; + bottom: -1px; + left: 12px; + height: 2px; + background: transparent; +} + +.main-nav a:hover { color: var(--text-soft); } +.main-nav a.is-active { color: var(--text); } +.main-nav a.is-active::after { background: var(--accent); } + +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.system-state { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-soft); + font-size: 12px; + white-space: nowrap; +} + +.status-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--status-disabled); + flex: 0 0 auto; +} + +.status--ok { --status-color: var(--status-ok); } +.status--stale { --status-color: var(--status-warning); } +.status--error { --status-color: var(--status-error); } +.status--disabled { --status-color: var(--status-disabled); } +.status-dot.status--ok, +.status-dot.status--stale, +.status-dot.status--error, +.status-dot.status--disabled { background: var(--status-color); } + +.icon-button { + display: inline-grid; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: transparent; + color: var(--text-soft); + cursor: pointer; + font-size: 20px; + line-height: 1; +} + +.icon-button:hover { background: var(--surface-raised); color: var(--text); } + +.app-shell { + width: min(100% - 40px, var(--max-width)); + margin: 0 auto; + padding: 48px 0 64px; +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 32px; + margin-bottom: 28px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { margin-top: 0; } + +h1 { + margin-bottom: 6px; + font-size: clamp(28px, 4vw, 38px); + font-weight: 600; + line-height: 1.15; + letter-spacing: -0.035em; +} + +h2 { + margin-bottom: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.lede { + margin-bottom: 0; + color: var(--text-soft); + font-size: 14px; +} + +.scan-state { + min-width: 150px; + padding-left: 18px; + border-left: 1px solid var(--border); +} + +.scan-state span, +.scan-state time { display: block; } + +.scan-state span { + margin-bottom: 4px; + color: var(--text-muted); + font-size: 11px; +} + +.scan-state time { + color: var(--text-soft); + font-family: var(--font-mono); + font-size: 13px; +} + +.metric-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; +} + +.metric { + min-height: 122px; + padding: 22px 24px; + border-right: 1px solid var(--border); +} + +.metric:last-child { border-right: 0; } + +.metric-label { + display: block; + margin-bottom: 16px; + color: var(--text-muted); + font-size: 12px; +} + +.metric strong { + display: block; + font-family: var(--font-mono); + font-size: clamp(23px, 3vw, 31px); + font-weight: 500; + line-height: 1; + letter-spacing: -0.04em; +} + +.metric small { + display: block; + margin-top: 8px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; +} + +.metric--issue.has-issues strong { color: var(--status-error); } + +.text-button { + margin-top: 8px; + padding: 0; + border: 0; + background: none; + color: var(--text-muted); + cursor: pointer; + font-size: 11px; + text-decoration: underline; + text-underline-offset: 3px; +} + +.text-button:hover { color: var(--text); } + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); + gap: 20px; + margin-bottom: 20px; +} + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.panel-header { + padding: 19px 22px; + border-bottom: 1px solid var(--border); +} + +.panel-header--split { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.panel-header p { + margin: 5px 0 0; + color: var(--text-muted); + font-size: 12px; +} + +.quiet-note { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; +} + +.chart { + height: 248px; + padding: 26px 22px 20px; +} + +.chart svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.cluster-list { + max-height: 248px; + padding: 10px 12px 14px; + overflow-y: auto; +} + +.cluster-group + .cluster-group { margin-top: 14px; } + +.cluster-heading { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 8px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.cluster-row { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; +} + +.cluster-row:hover { background: var(--surface-hover); } +.cluster-row strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 13px; } +.cluster-row small { color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; } + +.status-pill { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--status-color) 35%, transparent); + border-radius: 999px; + color: var(--status-color); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.event-list { padding: 4px 20px 10px; } + +.event-row { + display: grid; + grid-template-columns: 70px 8px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 44px; + border-bottom: 1px solid var(--border); +} + +.event-row:last-child { border-bottom: 0; } +.event-time { color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; } +.event-message { min-width: 0; color: var(--text-soft); font-size: 13px; } +.event-message strong { color: var(--text); font-weight: 600; } +.event-detail { max-width: 260px; overflow: hidden; color: var(--status-error); font-family: var(--font-mono); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } + +.inline-counts { + display: flex; + margin: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.inline-counts > div { + min-width: 112px; + padding: 12px 16px; +} + +.inline-counts > div + div { border-left: 1px solid var(--border); } +.inline-counts dt { color: var(--text-muted); font-size: 11px; } +.inline-counts dd { margin: 2px 0 0; font-family: var(--font-mono); font-size: 20px; } + +.alert-list, +.host-grid { display: grid; gap: 10px; } + +.alert-row { + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 72px; + padding: 14px 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.alert-copy h3 { margin: 0 0 4px; font-size: 14px; font-weight: 600; } +.alert-meta { display: flex; flex-wrap: wrap; gap: 14px; color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; } + +.empty-state { + margin: 0; + padding: 48px 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + text-align: center; +} + +.host-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + +.host-card { + width: 100%; + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + cursor: pointer; + text-align: left; +} + +.host-card:hover { border-color: var(--border-strong); background: var(--surface-raised); } +.host-card.is-disabled { opacity: 0.55; } + +.host-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 18px; +} + +.host-card-header strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; } + +.host-facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin: 0; +} + +.host-facts div { min-width: 0; } +.host-facts dt { color: var(--text-muted); font-size: 10px; } +.host-facts dd { margin: 3px 0 0; overflow: hidden; font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } + +.settings-list { + max-width: 760px; + margin: 0; + border-top: 1px solid var(--border); +} + +.settings-list > div { + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(220px, 2fr); + gap: 24px; + padding: 18px 0; + border-bottom: 1px solid var(--border); +} + +.settings-list dt { color: var(--text-soft); } +.settings-list dd { margin: 0; color: var(--text-muted); } +code { color: var(--accent); font-family: var(--font-mono); font-size: 12px; } + +.button { + min-height: 40px; + padding: 0 15px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface-raised); + color: var(--text-soft); + cursor: pointer; + font-size: 13px; + font-weight: 600; +} + +.button:hover { background: var(--surface-hover); color: var(--text); } +.button--primary { border-color: #537c5d; background: var(--accent-soft); color: #bfe3c6; } +.button--primary:hover { background: #29432f; } +.button--danger { border-color: transparent; background: transparent; color: var(--status-error); } + +.backdrop { + position: fixed; + inset: 0; + z-index: 40; + background: rgba(0, 0, 0, 0.62); + opacity: 1; + transition: opacity 150ms ease; +} + +.backdrop.is-hidden { opacity: 0; pointer-events: none; } + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 50; + width: min(540px, 94vw); + border-left: 1px solid var(--border); + background: var(--canvas); + transform: translateX(0); + transition: transform 180ms ease; +} + +.drawer.is-closed { transform: translateX(100%); pointer-events: none; } + +.modal { + position: fixed; + top: 50%; + left: 50%; + z-index: 60; + width: min(440px, calc(100vw - 32px)); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + transform: translate(-50%, -50%) scale(1); + opacity: 1; + transition: opacity 150ms ease, transform 150ms ease; +} + +.modal.is-closed { + transform: translate(-50%, -48%) scale(0.98); + opacity: 0; + pointer-events: none; +} + +.overlay-header { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + border-bottom: 1px solid var(--border); +} + +.overlay-header h2 { font-size: 17px; } +.drawer-body { height: calc(100% - 68px); padding: 22px; overflow-y: auto; } + +.drawer-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.drawer-stat { padding: 14px; text-align: left; } +.drawer-stat + .drawer-stat { border-left: 1px solid var(--border); } +.drawer-stat strong { display: block; font-family: var(--font-mono); font-size: 18px; font-weight: 500; } +.drawer-stat span { display: block; margin-top: 5px; color: var(--text-muted); font-size: 10px; } + +.detail-section { margin-top: 24px; } +.detail-section h3 { margin-bottom: 10px; color: var(--text-muted); font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; } + +.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; } +.calendar-day { aspect-ratio: 1; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 3px; color: var(--text-muted); font-family: var(--font-mono); font-size: 9px; } +.calendar-day.has-backup { border-color: #35533c; background: #1b2b20; color: #9fd0aa; } +.calendar-day.has-error { border-color: #5a3533; background: #2c1d1c; color: #e0a09b; } + +.size-bars { height: 68px; display: flex; align-items: end; gap: 2px; border-bottom: 1px solid var(--border); } +.size-bar { flex: 1; min-width: 2px; background: var(--accent); opacity: 0.65; } +.size-bar.is-empty { opacity: 0.12; } + +.history-list { border-top: 1px solid var(--border); } +.history-row { display: grid; grid-template-columns: 92px 8px 1fr auto auto; align-items: center; gap: 10px; min-height: 39px; border-bottom: 1px solid var(--border); font-size: 11px; } +.history-date { color: var(--text-muted); font-family: var(--font-mono); } +.history-size, +.history-files { color: var(--text-muted); font-family: var(--font-mono); text-align: right; } + +.drawer-actions { display: flex; gap: 8px; margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); } + +.form-stack { display: grid; gap: 18px; padding: 22px; } +.form-stack label > span { display: block; margin-bottom: 7px; color: var(--text-soft); font-size: 12px; } +.form-stack input[type="text"], +.form-stack input[type="url"] { width: 100%; min-height: 42px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--canvas); color: var(--text); } +.form-stack input::placeholder { color: #667066; } +.checkbox-row { display: flex; align-items: center; gap: 9px; } +.checkbox-row span { margin: 0 !important; } +.form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 6px; } + +.toast-stack { position: fixed; right: 20px; bottom: 20px; z-index: 80; display: grid; gap: 8px; } +.toast { min-width: 220px; padding: 11px 13px; border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--surface-raised); color: var(--text-soft); font-size: 12px; } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: var(--border-strong); background-clip: padding-box; } + +@media (max-width: 900px) { + .topbar { grid-template-columns: auto 1fr; gap: 16px; padding: 10px 0; } + .main-nav { grid-row: 2; grid-column: 1 / -1; min-height: 40px; overflow-x: auto; border-top: 1px solid var(--border); } + .main-nav a { min-height: 40px; padding: 0 10px; } + .topbar-actions { justify-self: end; } + .dashboard-grid { grid-template-columns: 1fr; } + .host-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 720px) { + .topbar, + .app-shell { width: min(100% - 24px, var(--max-width)); } + .topbar { gap: 10px; } + .brand small { display: none; } + .system-state span:last-child { display: none; } + .system-state { min-width: 44px; min-height: 44px; padding: 0 17px; } + .icon-button { width: 44px; height: 44px; } + .main-nav, + .main-nav a { min-height: 44px; } + .app-shell { padding-top: 32px; } + .page-header { align-items: flex-start; flex-direction: column; gap: 16px; } + .scan-state { padding-left: 0; border-left: 0; } + .metric-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metric { min-height: 112px; padding: 18px; } + .metric:nth-child(2) { border-right: 0; } + .metric:nth-child(-n + 2) { border-bottom: 1px solid var(--border); } + .panel-header { padding: 16px; } + .chart { height: 210px; padding: 22px 16px 16px; } + .event-list { padding: 4px 14px 8px; } + .event-row { grid-template-columns: 56px 8px minmax(0, 1fr); } + .event-detail { display: none; } + .host-grid { grid-template-columns: 1fr; } + .alert-row { grid-template-columns: 8px minmax(0, 1fr); } + .alert-row .button { grid-column: 2; justify-self: start; } + .inline-counts { width: 100%; } + .inline-counts > div { min-width: 0; flex: 1; } + .settings-list > div { grid-template-columns: 1fr; gap: 6px; } + .drawer-body { padding: 16px; } + .history-row { grid-template-columns: 76px 7px 1fr auto; } + .history-files { display: none; } + .toast-stack { right: 12px; bottom: 12px; left: 12px; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} 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, '''); +} diff --git a/backup-monitor/templates/index.html b/backup-monitor/templates/index.html index 6eb279d..8a8a2e4 100644 --- a/backup-monitor/templates/index.html +++ b/backup-monitor/templates/index.html @@ -1,295 +1,201 @@ - - + + - - -The Sentinel – Backup Monitor - - - - - - + + + + The Sentinel · Backup-Monitor + + - + +