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 `
-
-
- ${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 = `
-
-
-
-
-
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
+
+
-
+
+