diff --git a/backup-monitor/static/css/style.css b/backup-monitor/static/css/style.css index d0afffd..9452692 100644 --- a/backup-monitor/static/css/style.css +++ b/backup-monitor/static/css/style.css @@ -1,721 +1 @@ -: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; - } -} +/* Sentinel Design System – all styles via Tailwind CSS */ diff --git a/backup-monitor/static/js/app.js b/backup-monitor/static/js/app.js index 970cf5e..73ccfd8 100644 --- a/backup-monitor/static/js/app.js +++ b/backup-monitor/static/js/app.js @@ -1,323 +1,268 @@ -/* The Sentinel – Backup Monitor */ +/* ── The Sentinel – Backup Monitor Frontend ────────────────── */ const API = ''; let apiKey = localStorage.getItem('bm_api_key') || ''; let allHosts = []; let currentPage = 'dashboard'; -const statusLabels = { - ok: 'OK', - stale: 'überfällig', - error: 'Fehler', - disabled: 'aus', -}; - +// ── Init ────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { loadAll(); setInterval(loadAll, 30000); }); -document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') { - closeDrawer(); - closeModal(); - } -}); - function authHeaders() { - const headers = {'Content-Type': 'application/json'}; - if (apiKey) headers['X-API-Key'] = apiKey; - return headers; + const h = {'Content-Type': 'application/json'}; + if (apiKey) h['X-API-Key'] = apiKey; + return h; } -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); - } +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); } } - return response; + return r; } async function loadAll() { - 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); - } + 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'; } } -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'; - } -} +// ── 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 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)); + // 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' ? 'Erfolgreich' : 'Fehler'; + document.getElementById('mLatest').textContent = sorted[0].last_status === 'ok' ? 'Success' : 'Error'; document.getElementById('mLatestHost').textContent = sorted[0].name; - } else { - document.getElementById('mLatest').textContent = '–'; - document.getElementById('mLatestHost').textContent = 'Noch keine Meldung'; } - 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(''); + // 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(); } -function clusterGroup(label, hosts, status) { +function clusterGroup(label, hosts, color) { return ` -
-
${label} · ${hosts.length}
- ${hosts.map(host => ` - - `).join('')} -
`; +
+
${label}
+ ${hosts.map(h => ` +
+
+
${h.name}
+
schedule ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}
+
+ ${h.status.toUpperCase()} +
+ `).join('')} +
`; } function renderLiveStream() { - 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'; + 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 ` -
- - - ${escapeHtml(host.name)} ${failed ? 'hat einen Fehler gemeldet' : 'wurde erfolgreich gesichert'} - ${escapeHtml(host.last_message || '')} -
`; +
+ ${t} +
+ ${isErr ? 'ERROR: ' : ''}Backup for ${h.name} ${isErr ? 'failed' : 'completed successfully'}. + ${h.last_message ? `${h.last_message}` : ''} +
`; }).join(''); } async function loadVolumeChart() { + // Aggregate daily totals from all hosts const days = []; - 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])); + 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); - 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); - } + // 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`; - 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(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 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 list = document.getElementById('alertList'); - if (!issues.length) { - list.innerHTML = '

Keine offenen Meldungen. Alle aktiven Hosts sind im Zeitfenster.

'; - return; - } + const al = document.getElementById('alertList'); + if (!issues.length) { al.innerHTML = '
No active alerts – all systems operational ✓
'; return; } - list.innerHTML = issues.map(host => { - const failed = host.status === 'error'; + 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 ` -
- -
-

${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`)} +
+
+
+ ${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(host => ` - + 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) { - const drawer = document.getElementById('drawer'); document.getElementById('drawerTitle').textContent = name; - document.getElementById('drawerBg').classList.remove('is-hidden'); - drawer.classList.remove('is-closed'); - drawer.setAttribute('aria-hidden', 'false'); + document.getElementById('drawerBg').classList.remove('opacity-0','pointer-events-none'); + document.getElementById('drawer').classList.remove('translate-x-full'); const body = document.getElementById('drawerBody'); - body.innerHTML = '

Details werden geladen …

'; + body.innerHTML = '
Loading...
'; - try { - const [historyResponse, calendarResponse] = await Promise.all([ - fetch(`${API}/api/history/${encodeURIComponent(name)}?days=30`), - fetch(`${API}/api/calendar/${encodeURIComponent(name)}?days=30`), - ]); - const history = await historyResponse.json(); - const calendar = await calendarResponse.json(); - const totalSize = history.reduce((sum, entry) => sum + Number(entry.original_size || 0), 0); - const successRate = history.length - ? Math.round(history.filter(entry => entry.status === 'ok').length / history.length * 100) - : 0; + const [histR, calR] = await Promise.all([fetch(`${API}/api/history/${name}?days=30`), fetch(`${API}/api/calendar/${name}?days=30`)]); + const history = await histR.json(); + const calendar = await calR.json(); + const host = allHosts.find(h => h.name === name) || {}; - body.innerHTML = ` -
-
${history.length}Sicherungen
-
${successRate}%Erfolgreich
-
${fmtBytes(totalSize)}Volumen
+ const totalSize = history.reduce((s,e) => s + e.original_size, 0); + const avgDur = history.length ? Math.round(history.reduce((s,e) => s + e.duration_sec, 0) / history.length) : 0; + const rate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0; + + body.innerHTML = ` + +
+
${history.length}
Backups
+
${rate}%
Success
+
${fmtDuration(avgDur)}
Avg Duration
+
+ + +

30-Day Calendar

+
${buildCalendar(calendar)}
+ + +

Data Volume

+
${buildSizeChart(history)}
+ + +

Recent Backups

+
+ ${history.slice(0, 15).map(e => ` +
+ ${new Date(e.timestamp).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})} + + ${fmtDuration(e.duration_sec)} + ${fmtBytes(e.original_size)} + ${e.nfiles_new ? `+${e.nfiles_new}` : ''}
+ `).join('')} +
-
-

30 Tage

-
${buildCalendar(calendar)}
-
- -
-

Datenvolumen

-
${buildSizeChart(history)}
-
- -
-

Letzte Sicherungen

-
- ${history.slice(0, 15).map(entry => ` -
- - - ${fmtDuration(entry.duration_sec)} - ${fmtBytes(entry.original_size)} - ${entry.nfiles_new ? `+${entry.nfiles_new}` : ''} -
- `).join('') || '

Keine Einträge im Zeitraum.

'} -
-
- -
- - -
`; - } catch (error) { - body.innerHTML = '

Die Host-Details konnten nicht geladen werden.

'; - console.error('Host-Details konnten nicht geladen werden:', error); - } + +
+ + +
+ `; } -function buildCalendar(calendar) { +function buildCalendar(cal) { const days = []; - for (let offset = 29; offset >= 0; offset -= 1) { - const date = new Date(); - date.setDate(date.getDate() - offset); - const key = date.toISOString().split('T')[0]; - const data = calendar[key]; - if (!data) { - days.push(`
${date.getDate()}
`); - } else { - const state = data.has_error ? 'has-error' : 'has-backup'; - days.push(`
${date.getDate()}
`); + for (let i = 29; i >= 0; i--) { + const d = new Date(); d.setDate(d.getDate() - i); + const key = d.toISOString().split('T')[0]; + const data = cal[key]; + const num = d.getDate(); + if (!data) { days.push(`
${num}
`); } + else { + const cls = data.has_error ? 'bg-error/20 text-error' : 'bg-secondary/20 text-secondary'; + days.push(`
${num}
`); } } return days.join(''); @@ -325,149 +270,91 @@ function buildCalendar(calendar) { function buildSizeChart(history) { const byDay = {}; - history.forEach(entry => { - const day = entry.timestamp.split('T')[0]; - byDay[day] = (byDay[day] || 0) + Number(entry.original_size || 0); - }); + history.forEach(e => { const d = e.timestamp.split('T')[0]; byDay[d] = (byDay[d]||0) + e.original_size; }); const days = []; - for (let offset = 29; offset >= 0; offset -= 1) { - const date = new Date(); - date.setDate(date.getDate() - offset); - const key = date.toISOString().split('T')[0]; - days.push({key, size: byDay[key] || 0}); - } - const maximum = Math.max(...days.map(day => day.size), 1); - return days.map(day => { - const height = day.size ? Math.max(6, (day.size / maximum) * 100) : 3; - return `
`; + for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate()-i); days.push({key:d.toISOString().split('T')[0], size: byDay[d.toISOString().split('T')[0]]||0}); } + const max = Math.max(...days.map(d=>d.size), 1); + return days.map(d => { + const h = d.size ? Math.max(6, (d.size/max)*100) : 4; + return `
`; }).join(''); } function closeDrawer() { - const drawer = document.getElementById('drawer'); - document.getElementById('drawerBg').classList.add('is-hidden'); - drawer.classList.add('is-closed'); - drawer.setAttribute('aria-hidden', 'true'); + document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none'); + document.getElementById('drawer').classList.add('translate-x-full'); } +// ── Modal ───────────────────────────────────────────────── function openAddHost() { - document.getElementById('modalTitle').textContent = 'Host hinzufügen'; + document.getElementById('modalTitle').textContent = 'Add Host'; document.getElementById('formMode').value = 'add'; - document.getElementById('formName').value = ''; - document.getElementById('formName').disabled = false; + document.getElementById('formName').value = ''; document.getElementById('formName').disabled = false; document.getElementById('formKumaUrl').value = ''; document.getElementById('formEnabled').checked = true; openModal(); } -function openEditHost(name) { +async function openEditHost(name) { closeDrawer(); - const host = allHosts.find(item => item.name === name); - if (!host) return; - document.getElementById('modalTitle').textContent = `${name} bearbeiten`; + const h = allHosts.find(x => x.name === name); if (!h) return; + document.getElementById('modalTitle').textContent = `Edit: ${name}`; document.getElementById('formMode').value = 'edit'; - document.getElementById('formName').value = host.name; - document.getElementById('formName').disabled = true; - document.getElementById('formKumaUrl').value = host.kuma_push_url || ''; - document.getElementById('formEnabled').checked = host.enabled; + document.getElementById('formName').value = h.name; document.getElementById('formName').disabled = true; + document.getElementById('formKumaUrl').value = h.kuma_push_url || ''; + document.getElementById('formEnabled').checked = h.enabled; openModal(); } -async function saveHost(event) { - event.preventDefault(); +async function saveHost(e) { + e.preventDefault(); const mode = document.getElementById('formMode').value; const name = document.getElementById('formName').value.trim(); - const kumaUrl = document.getElementById('formKumaUrl').value.trim(); + const kuma = document.getElementById('formKumaUrl').value.trim(); const enabled = document.getElementById('formEnabled').checked; if (mode === 'add') { - await apiFetch(`${API}/api/hosts`, { - method: 'POST', headers: authHeaders(), body: JSON.stringify({name, kuma_push_url: kumaUrl}), - }); - toast(`${name} wurde hinzugefügt`); + await apiFetch(`${API}/api/hosts`, { method:'POST', headers:authHeaders(), body:JSON.stringify({name, kuma_push_url:kuma}) }); + toast(`${name} added`); } else { - await apiFetch(`${API}/api/hosts/${encodeURIComponent(name)}`, { - method: 'PUT', headers: authHeaders(), body: JSON.stringify({kuma_push_url: kumaUrl, enabled}), - }); - toast(`${name} wurde aktualisiert`); + await apiFetch(`${API}/api/hosts/${name}`, { method:'PUT', headers:authHeaders(), body:JSON.stringify({kuma_push_url:kuma, enabled}) }); + toast(`${name} updated`); } - closeModal(); - loadAll(); + closeModal(); loadAll(); } async function confirmDelete(name) { - if (!confirm(`Host "${name}" einschließlich Historie löschen?`)) return; - await apiFetch(`${API}/api/hosts/${encodeURIComponent(name)}`, {method: 'DELETE', headers: authHeaders()}); - toast(`${name} wurde gelöscht`); - closeDrawer(); - loadAll(); + if (!confirm(`Delete "${name}" and all history?`)) return; + await apiFetch(`${API}/api/hosts/${name}`, { method:'DELETE', headers:authHeaders() }); + toast(`${name} deleted`); closeDrawer(); loadAll(); } -function openModal() { - const modal = document.getElementById('modal'); - document.getElementById('modalBg').classList.remove('is-hidden'); - modal.classList.remove('is-closed'); - modal.setAttribute('aria-hidden', 'false'); - setTimeout(() => document.getElementById('formName').focus(), 0); -} - -function closeModal() { - const modal = document.getElementById('modal'); - document.getElementById('modalBg').classList.add('is-hidden'); - modal.classList.add('is-closed'); - modal.setAttribute('aria-hidden', 'true'); -} +function openModal() { document.getElementById('modalBg').classList.remove('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.remove('scale-95','opacity-0','pointer-events-none'); } +function closeModal() { document.getElementById('modalBg').classList.add('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.add('scale-95','opacity-0','pointer-events-none'); } +// ── Navigation ──────────────────────────────────────────── function showPage(page) { currentPage = page; - ['dashboard', 'alerts', 'hosts', 'config'].forEach(item => { - document.getElementById(`page-${item}`).classList.toggle('hidden', item !== page); - const nav = document.getElementById(`nav-${item}`); - nav.classList.toggle('is-active', item === page); - if (item === page) nav.setAttribute('aria-current', 'page'); - else nav.removeAttribute('aria-current'); + ['dashboard','alerts','hosts','config'].forEach(p => { + document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page); + // Nav highlights + const nav = document.getElementById(`nav-${p}`); + const side = document.getElementById(`side-${p}`); + if (nav) { nav.className = p === page ? 'text-sm font-bold tracking-tight text-blue-400 px-3 py-1 rounded-lg font-headline' : 'text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline'; } + if (side) { side.className = p === page ? 'flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-600/10 text-blue-400 border-r-2 border-blue-500 font-headline text-sm font-semibold' : 'flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-900/80 transition-all font-headline text-sm font-semibold'; } }); - window.scrollTo({top: 0, behavior: 'smooth'}); } -function toast(message) { - const element = document.createElement('div'); - element.className = 'toast'; - element.textContent = message; - document.getElementById('toasts').appendChild(element); - setTimeout(() => element.remove(), 4000); +// ── Toast ───────────────────────────────────────────────── +function toast(msg) { + const t = document.createElement('div'); + t.className = 'glass px-5 py-3 rounded-xl text-sm font-medium text-white shadow-2xl flex items-center gap-2 animate-[slideIn_0.3s_ease-out]'; + t.innerHTML = `check_circle ${msg}`; + document.getElementById('toasts').appendChild(t); + setTimeout(() => t.remove(), 4000); } -function fmtBytes(bytes) { - const value = Number(bytes || 0); - if (!value) return '0 B'; - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; - const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); - const number = value / Math.pow(1024, index); - return `${number.toLocaleString('de-DE', {maximumFractionDigits: index > 0 ? 1 : 0})} ${units[index]}`; -} - -function fmtDuration(seconds) { - const value = Number(seconds || 0); - if (value <= 0) return '–'; - if (value < 60) return `${value.toLocaleString('de-DE', {maximumFractionDigits: 1})} s`; - if (value < 3600) return `${Math.floor(value / 60)} min`; - return `${Math.floor(value / 3600)} h ${Math.floor((value % 3600) / 60)} min`; -} - -function timeAgo(iso) { - const seconds = (Date.now() - new Date(iso).getTime()) / 1000; - if (seconds < 60) return 'gerade eben'; - if (seconds < 3600) return `vor ${Math.floor(seconds / 60)} min`; - if (seconds < 86400) return `vor ${Math.floor(seconds / 3600)} h`; - return `vor ${Math.floor(seconds / 86400)} T.`; -} - -function escapeHtml(value) { - return String(value ?? '').replace(/[&<>"]/g, character => ({ - '&': '&', '<': '<', '>': '>', '"': '"', - })[character]); -} - -function escapeAttr(value) { - return escapeHtml(value).replace(/'/g, '''); -} +// ── Helpers ─────────────────────────────────────────────── +function fmtBytes(b) { if (!b) return '0 B'; const u = ['B','KB','MB','GB','TB']; const i = Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i>0?1:0)+' '+u[i]; } +function fmtDuration(s) { if (!s) return '–'; if (s<60) return s+'s'; if (s<3600) return Math.floor(s/60)+'m'; return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; } +function timeAgo(iso) { const d=(Date.now()-new Date(iso).getTime())/1000; if(d<60) return 'just now'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; } +function statusChipClass(s) { return { ok:'bg-secondary/10 text-secondary border border-secondary/20', error:'bg-error/10 text-error border border-error/20', stale:'bg-tertiary/10 text-tertiary border border-tertiary/20', disabled:'bg-outline/10 text-outline border border-outline/20' }[s] || ''; } diff --git a/backup-monitor/templates/index.html b/backup-monitor/templates/index.html index 8a8a2e4..6eb279d 100644 --- a/backup-monitor/templates/index.html +++ b/backup-monitor/templates/index.html @@ -1,201 +1,295 @@ - - + + - - - - The Sentinel · Backup-Monitor - - + + +The Sentinel – Backup Monitor + + + + + + - -