Compare commits

..

No commits in common. "c3595ae79fb51903af9fbcb30915c42985ab4239" and "7da281117ad995fb516a49404cc29ccc6aad6179" have entirely different histories.

4 changed files with 526 additions and 1342 deletions

View file

@ -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 */

View file

@ -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 = '<span class="status-dot status--error" aria-hidden="true"></span><span>Nicht erreichbar</span>';
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 = '<span class="material-symbols-outlined text-sm text-error" style="font-variation-settings:\'FILL\' 1">error</span><span class="font-headline text-xs font-medium text-error">Errors Active</span>'; }
else if (sum.stale > 0) { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-tertiary" style="font-variation-settings:\'FILL\' 1">warning</span><span class="font-headline text-xs font-medium text-tertiary">Stale Hosts</span>'; }
else { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-secondary" style="font-variation-settings:\'FILL\' 1">cloud_done</span><span class="font-headline text-xs font-medium text-slate-300">All Systems OK</span>'; }
}
function renderSystemState(summary) {
const state = document.getElementById('sysStatus');
if (summary.error > 0) {
state.className = 'system-state status--error';
state.innerHTML = '<span class="status-dot status--error" aria-hidden="true"></span><span>Fehler aktiv</span>';
} else if (summary.stale > 0) {
state.className = 'system-state status--stale';
state.innerHTML = '<span class="status-dot status--stale" aria-hidden="true"></span><span>Hosts überfällig</span>';
} else {
state.className = 'system-state status--ok';
state.innerHTML = '<span class="status-dot status--ok" aria-hidden="true"></span><span>Betriebsbereit</span>';
}
}
// ── 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 `
<div class="cluster-group">
<div class="cluster-heading"><span class="status-dot status--${status}"></span>${label} · ${hosts.length}</div>
${hosts.map(host => `
<button type="button" class="cluster-row" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">
<span class="status-dot status--${host.status}"></span>
<strong>${escapeHtml(host.name)}</strong>
<small>${host.last_backup ? timeAgo(host.last_backup) : 'noch nie'}</small>
</button>
`).join('')}
</div>`;
<div class="mb-4">
<div class="flex items-center gap-2 mb-3"><div class="w-1 h-4 bg-${color} rounded-full"></div><span class="text-xs font-black uppercase tracking-widest text-${color}">${label}</span></div>
${hosts.map(h => `
<div onclick="openHost('${h.name}')" class="flex items-center justify-between px-4 py-3 rounded-lg bg-surface-container hover:bg-surface-container-high transition-all cursor-pointer mb-2">
<div>
<div class="text-sm font-bold text-white font-headline">${h.name}</div>
<div class="text-[11px] text-on-surface-variant flex items-center gap-1"><span class="material-symbols-outlined text-[12px]">schedule</span> ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</div>
</div>
<span class="px-2 py-0.5 rounded text-[10px] font-black tracking-wider ${statusChipClass(h.status)}">${h.status.toUpperCase()}</span>
</div>
`).join('')}
</div>`;
}
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 = '<p class="empty-state">Noch keine Backup-Meldungen.</p>';
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 `
<div class="event-row">
<time class="event-time">${time}</time>
<span class="status-dot status--${failed ? 'error' : 'ok'}"></span>
<span class="event-message"><strong>${escapeHtml(host.name)}</strong> ${failed ? 'hat einen Fehler gemeldet' : 'wurde erfolgreich gesichert'}</span>
<span class="event-detail" title="${escapeAttr(host.last_message || '')}">${escapeHtml(host.last_message || '')}</span>
</div>`;
<div class="flex items-center gap-4 px-4 py-3 rounded-lg hover:bg-surface-container transition-colors ${isErr ? 'bg-error-container/5' : ''}">
<span class="text-xs font-mono text-slate-500 w-16 shrink-0">${t}</span>
<div class="w-2.5 h-2.5 rounded-full ${isErr ? 'bg-error pulse-err' : 'bg-secondary'} shrink-0"></div>
<span class="text-sm flex-1">${isErr ? '<span class="text-error font-bold">ERROR:</span> ' : ''}Backup for <span class="font-bold text-white">${h.name}</span> ${isErr ? 'failed' : 'completed successfully'}.</span>
${h.last_message ? `<span class="text-[10px] font-mono text-error/80">${h.last_message}</span>` : ''}
</div>`;
}).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 = `
<defs>
<linearGradient id="volume-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#7abf8a" stop-opacity="0.24"/>
<stop offset="100%" stop-color="#7abf8a" stop-opacity="0"/>
</linearGradient>
</defs>
${[20, 40, 60, 80].map(y => `<line x1="0" y1="${y}" x2="100" y2="${y}" stroke="#2c312c" stroke-width="0.35"/>`).join('')}
<path d="${fill}" fill="url(#volume-fill)"/>
<path d="${path}" fill="none" stroke="#7abf8a" stroke-width="1.2" vector-effect="non-scaling-stroke"/>
<defs><linearGradient id="cg" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#adc6ff" stop-opacity="0.2"/><stop offset="100%" stop-color="#adc6ff" stop-opacity="0"/></linearGradient></defs>
${[20,40,60,80].map(y => `<line x1="0" y1="${y}" x2="100" y2="${y}" stroke="#1e293b" stroke-width="0.3"/>`).join('')}
<path d="${fillD}" fill="url(#cg)"/>
<path d="${pathD}" fill="none" stroke="#adc6ff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
`;
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 = '<p class="empty-state">Keine offenen Meldungen. Alle aktiven Hosts sind im Zeitfenster.</p>';
return;
}
const al = document.getElementById('alertList');
if (!issues.length) { al.innerHTML = '<div class="text-center py-16 text-on-surface-variant text-sm">No active alerts all systems operational ✓</div>'; 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 `
<article class="alert-row">
<span class="status-dot status--${failed ? 'error' : 'stale'}"></span>
<div class="alert-copy">
<h3>${failed ? 'Sicherung fehlgeschlagen' : 'Sicherung überfällig'} · ${escapeHtml(host.name)}</h3>
<div class="alert-meta">
<span>${host.last_backup ? timeAgo(host.last_backup) : 'noch keine Sicherung'}</span>
<span>${escapeHtml(host.last_message || `${Math.round(host.age_hours || 0)} Stunden ohne Meldung`)}</span>
<div class="bg-surface-container-low hover:bg-surface-container transition-all rounded-xl group">
<div class="flex flex-col md:flex-row items-start md:items-center gap-4 px-6 py-5">
<div class="w-12 h-12 rounded-full bg-${color}/10 flex items-center justify-center shrink-0 ${isCrit ? 'pulse-err' : ''}">
<span class="material-symbols-outlined text-${color}" style="font-variation-settings:'FILL' 1">${icon}</span>
</div>
<div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-white font-bold font-headline">${isCrit ? 'Backup Failed' : 'Backup Overdue'} ${h.name}</h3>
<span class="px-2 py-0.5 rounded text-[10px] font-black bg-${color}/10 text-${color} border border-${color}/20 tracking-wider">${label}</span>
</div>
<div class="flex items-center gap-4 text-xs text-on-surface-variant">
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">dns</span> ${h.name}</span>
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">schedule</span> ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</span>
${h.last_message ? `<span class="text-${color}/80 italic">${h.last_message}</span>` : `<span class="text-${color}/80 italic">${Math.round(h.age_hours)}h without backup</span>`}
</div>
</div>
<button type="button" class="button" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">Details</button>
</article>`;
<div class="flex items-center gap-2">
<button onclick="openHost('${h.name}')" class="bg-surface-variant hover:bg-surface-container-highest text-on-surface-variant px-5 py-2 rounded-lg text-xs font-bold transition-all">Details</button>
</div>
</div>
</div>`;
}).join('');
}
// ── Host Grid ─────────────────────────────────────────────
function renderHostGrid() {
const grid = document.getElementById('hostGrid');
grid.innerHTML = allHosts.map(host => `
<button type="button" class="host-card ${host.status === 'disabled' ? 'is-disabled' : ''}" data-host="${escapeAttr(host.name)}" onclick="openHost(this.dataset.host)">
<span class="host-card-header">
<strong>${escapeHtml(host.name)}</strong>
<span class="status-pill status--${host.status}">${statusLabels[host.status] || host.status}</span>
</span>
<dl class="host-facts">
<div><dt>Letzte Sicherung</dt><dd>${host.last_backup ? timeAgo(host.last_backup) : 'noch nie'}</dd></div>
<div><dt>Backups, 7 Tage</dt><dd>${host.backup_count_7d}</dd></div>
<div><dt>Ø Laufzeit</dt><dd>${fmtDuration(host.avg_duration_7d)}</dd></div>
<div><dt>Volumen, 7 Tage</dt><dd>${fmtBytes(host.total_size_7d)}</dd></div>
</dl>
</button>
grid.innerHTML = allHosts.map(h => `
<div onclick="openHost('${h.name}')" class="bg-surface-container-low hover:bg-surface-container rounded-xl p-6 cursor-pointer transition-all group relative overflow-hidden ${h.status === 'disabled' ? 'opacity-50' : ''}">
<div class="absolute top-0 left-0 w-1 h-full rounded-l-xl ${h.status === 'ok' ? 'bg-secondary' : h.status === 'error' ? 'bg-error' : h.status === 'stale' ? 'bg-tertiary' : 'bg-outline'}"></div>
<div class="flex justify-between items-start mb-4">
<div class="text-base font-bold text-white font-headline">${h.name}</div>
<span class="px-2 py-0.5 rounded text-[10px] font-black tracking-wider ${statusChipClass(h.status)}">${h.status.toUpperCase()}</span>
</div>
<div class="grid grid-cols-2 gap-3 text-xs">
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">Last Backup</span><span class="font-semibold text-white">${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</span></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">7d Backups</span><span class="font-semibold text-white">${h.backup_count_7d}</span></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">Avg Duration</span><span class="font-semibold text-white">${fmtDuration(h.avg_duration_7d)}</span></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">7d Volume</span><span class="font-semibold text-white">${fmtBytes(h.total_size_7d)}</span></div>
</div>
</div>
`).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 = '<p class="empty-state">Details werden geladen …</p>';
body.innerHTML = '<div class="text-center py-12 text-on-surface-variant">Loading...</div>';
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 = `
<div class="drawer-stats">
<div class="drawer-stat"><strong>${history.length}</strong><span>Sicherungen</span></div>
<div class="drawer-stat"><strong>${successRate}%</strong><span>Erfolgreich</span></div>
<div class="drawer-stat"><strong>${fmtBytes(totalSize)}</strong><span>Volumen</span></div>
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 = `
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-6">
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-primary">${history.length}</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Backups</div></div>
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-secondary">${rate}%</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Success</div></div>
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-primary">${fmtDuration(avgDur)}</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Avg Duration</div></div>
</div>
<!-- Calendar -->
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">30-Day Calendar</h4>
<div class="grid grid-cols-7 gap-1.5 mb-6">${buildCalendar(calendar)}</div>
<!-- Size Chart -->
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">Data Volume</h4>
<div class="flex items-end gap-[2px] h-16 mb-6">${buildSizeChart(history)}</div>
<!-- History -->
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">Recent Backups</h4>
<div class="space-y-0">
${history.slice(0, 15).map(e => `
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-surface-container transition-colors text-xs">
<span class="font-mono text-on-surface-variant w-24 shrink-0">${new Date(e.timestamp).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}</span>
<span class="w-2 h-2 rounded-full ${e.status === 'ok' ? 'bg-secondary' : 'bg-error'} shrink-0"></span>
<span class="flex-1 font-medium">${fmtDuration(e.duration_sec)}</span>
<span class="text-on-surface-variant">${fmtBytes(e.original_size)}</span>
<span class="text-on-surface-variant">${e.nfiles_new ? `+${e.nfiles_new}` : ''}</span>
</div>
`).join('')}
</div>
<section class="detail-section">
<h3>30 Tage</h3>
<div class="calendar-grid">${buildCalendar(calendar)}</div>
</section>
<section class="detail-section">
<h3>Datenvolumen</h3>
<div class="size-bars">${buildSizeChart(history)}</div>
</section>
<section class="detail-section">
<h3>Letzte Sicherungen</h3>
<div class="history-list">
${history.slice(0, 15).map(entry => `
<div class="history-row">
<time class="history-date">${new Date(entry.timestamp).toLocaleString('de-DE', {day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'})}</time>
<span class="status-dot status--${entry.status === 'ok' ? 'ok' : 'error'}"></span>
<span>${fmtDuration(entry.duration_sec)}</span>
<span class="history-size">${fmtBytes(entry.original_size)}</span>
<span class="history-files">${entry.nfiles_new ? `+${entry.nfiles_new}` : ''}</span>
</div>
`).join('') || '<p class="empty-state">Keine Einträge im Zeitraum.</p>'}
</div>
</section>
<div class="drawer-actions">
<button type="button" class="button" onclick="openEditHost(document.getElementById('drawerTitle').textContent)">Bearbeiten</button>
<button type="button" class="button button--danger" onclick="confirmDelete(document.getElementById('drawerTitle').textContent)">Löschen</button>
</div>`;
} catch (error) {
body.innerHTML = '<p class="empty-state">Die Host-Details konnten nicht geladen werden.</p>';
console.error('Host-Details konnten nicht geladen werden:', error);
}
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6 border-t border-outline-variant/10">
<button onclick="openEditHost('${name}')" class="bg-surface-container-high hover:bg-surface-container-highest px-5 py-2.5 rounded-lg text-xs font-bold transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-sm">settings</span> Edit
</button>
<button onclick="confirmDelete('${name}')" class="hover:bg-error/10 text-error px-5 py-2.5 rounded-lg text-xs font-bold transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-sm">delete</span> Delete
</button>
</div>
`;
}
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(`<div class="calendar-day" title="${key}: keine Sicherung">${date.getDate()}</div>`);
} else {
const state = data.has_error ? 'has-error' : 'has-backup';
days.push(`<div class="calendar-day ${state}" title="${key}: ${data.count} Sicherung(en), ${fmtBytes(data.total_size)}">${date.getDate()}</div>`);
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(`<div class="aspect-square rounded bg-surface-container flex items-center justify-center text-[10px] text-slate-600" title="${key}: No backup">${num}</div>`); }
else {
const cls = data.has_error ? 'bg-error/20 text-error' : 'bg-secondary/20 text-secondary';
days.push(`<div class="aspect-square rounded ${cls} flex items-center justify-center text-[10px] font-bold cursor-default" title="${key}: ${data.count}x, ${fmtBytes(data.total_size)}">${num}</div>`);
}
}
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 `<div class="size-bar ${day.size ? '' : 'is-empty'}" style="height:${height}%" title="${day.key}: ${fmtBytes(day.size)}"></div>`;
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 `<div class="flex-1 rounded-t bg-primary ${d.size ? 'opacity-70 hover:opacity-100' : 'opacity-15'} transition-opacity" style="height:${h}%" title="${d.key}: ${fmtBytes(d.size)}"></div>`;
}).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 = `<span class="material-symbols-outlined text-secondary text-sm" style="font-variation-settings:'FILL' 1">check_circle</span> ${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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;',
})[character]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, '&#39;');
}
// ── 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] || ''; }

View file

@ -1,201 +1,295 @@
<!doctype html>
<html lang="de">
<!DOCTYPE html>
<html class="dark" lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>The Sentinel · Backup-Monitor</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='10' fill='%23171917'/><path d='M18 20h28M18 32h28M18 44h18' stroke='%237abf8a' stroke-width='5' stroke-linecap='round'/></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>The Sentinel Backup Monitor</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface-dim": "#0b1326", "surface": "#0b1326", "surface-bright": "#31394d",
"surface-container-lowest": "#060e20", "surface-container-low": "#131b2e",
"surface-container": "#171f33", "surface-container-high": "#222a3d",
"surface-container-highest": "#2d3449", "surface-variant": "#2d3449",
"on-surface": "#dae2fd", "on-surface-variant": "#c1c6d6",
"primary": "#adc6ff", "primary-container": "#0069de", "on-primary": "#002e69",
"secondary": "#4edea3", "secondary-container": "#00a572", "on-secondary": "#003824",
"tertiary": "#ffb95f", "tertiary-container": "#9a6100",
"error": "#ffb4ab", "error-container": "#93000a", "on-error": "#690005",
"outline": "#8b909f", "outline-variant": "#414753",
},
fontFamily: { headline: ["Manrope"], body: ["Inter"], label: ["Inter"] },
borderRadius: { DEFAULT: "0.25rem", lg: "0.5rem", xl: "0.75rem", full: "9999px" },
},
},
}
</script>
<style>
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
.pulse-ok { animation: pulse-g 2s infinite; }
@keyframes pulse-g { 0%{box-shadow:0 0 0 0 rgba(78,222,163,.4)} 70%{box-shadow:0 0 0 10px rgba(78,222,163,0)} 100%{box-shadow:0 0 0 0 rgba(78,222,163,0)} }
.pulse-err { animation: pulse-r 2s infinite; }
@keyframes pulse-r { 0%{box-shadow:0 0 0 0 rgba(255,180,171,.4)} 70%{box-shadow:0 0 0 10px rgba(255,180,171,0)} 100%{box-shadow:0 0 0 0 rgba(255,180,171,0)} }
.glass { background: rgba(45,52,73,.6); backdrop-filter: blur(20px); }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #2d3449; border-radius: 3px; }
</style>
</head>
<body>
<header class="site-header">
<div class="topbar">
<a class="brand" href="#dashboard" onclick="showPage('dashboard'); return false;" aria-label="The Sentinel Startseite">
<span class="brand-mark" aria-hidden="true">S</span>
<span>
<strong>The Sentinel</strong>
<small>Backup-Monitor</small>
</span>
</a>
<body class="bg-surface-dim font-body text-on-surface antialiased">
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="#dashboard" onclick="showPage('dashboard'); return false;" id="nav-dashboard" class="is-active" aria-current="page">Übersicht</a>
<a href="#alerts" onclick="showPage('alerts'); return false;" id="nav-alerts">Meldungen</a>
<a href="#hosts" onclick="showPage('hosts'); return false;" id="nav-hosts">Hosts</a>
<a href="#config" onclick="showPage('config'); return false;" id="nav-config">Einstellungen</a>
</nav>
<!-- ── Top Nav ──────────────────────────────────────────── -->
<nav class="fixed top-0 w-full z-50 flex justify-between items-center px-6 h-16 bg-slate-900/60 backdrop-blur-xl shadow-2xl shadow-slate-950/40">
<div class="flex items-center gap-8">
<span class="text-xl font-bold tracking-tighter text-white font-headline">The Sentinel</span>
<div class="hidden md:flex gap-1 items-center">
<a href="#" onclick="showPage('dashboard')" id="nav-dashboard" class="text-sm font-bold tracking-tight text-blue-400 px-3 py-1 rounded-lg font-headline">Dashboard</a>
<a href="#" onclick="showPage('alerts')" id="nav-alerts" class="text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline">Alert Center</a>
<a href="#" onclick="showPage('hosts')" id="nav-hosts" class="text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline">Backup Hosts</a>
</div>
</div>
<div class="flex items-center gap-4">
<div id="sysStatus" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50">
<span class="material-symbols-outlined text-sm text-secondary" style="font-variation-settings:'FILL' 1">cloud_done</span>
<span class="font-headline text-xs font-medium text-slate-300">System OK</span>
</div>
<button onclick="loadAll()" class="material-symbols-outlined text-slate-400 hover:bg-slate-800/50 p-2 rounded-full transition-colors">refresh</button>
<button onclick="openAddHost()" class="material-symbols-outlined text-slate-400 hover:bg-slate-800/50 p-2 rounded-full transition-colors">add_circle</button>
</div>
</nav>
<div class="topbar-actions">
<div id="sysStatus" class="system-state status--ok" aria-live="polite">
<span class="status-dot status--ok" aria-hidden="true"></span>
<span>Betriebsbereit</span>
</div>
<button type="button" class="icon-button" onclick="loadAll()" aria-label="Daten aktualisieren" title="Aktualisieren"></button>
<button type="button" class="icon-button" onclick="openAddHost()" aria-label="Host hinzufügen" title="Host hinzufügen"></button>
<!-- ── Sidebar ─────────────────────────────────────────── -->
<aside class="fixed left-0 top-0 h-full flex-col py-6 bg-slate-950 w-56 z-40 pt-20 hidden lg:flex">
<div class="px-5 mb-8">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-primary-container flex items-center justify-center">
<span class="material-symbols-outlined text-white text-lg" style="font-variation-settings:'FILL' 1">shield</span>
</div>
<div>
<div class="text-sm font-black text-white font-headline leading-tight">The Sentinel</div>
<div class="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">Backup Monitor</div>
</div>
</div>
</div>
<div class="flex-1 px-3 space-y-1">
<a href="#" onclick="showPage('dashboard')" id="side-dashboard" class="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">
<span class="material-symbols-outlined">dashboard</span><span>Dashboard</span>
</a>
<a href="#" onclick="showPage('alerts')" id="side-alerts" class="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">
<span class="material-symbols-outlined">warning</span><span>Alert Center</span>
</a>
<a href="#" onclick="showPage('hosts')" id="side-hosts" class="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">
<span class="material-symbols-outlined">dns</span><span>Backup Hosts</span>
</a>
<a href="#" onclick="showPage('config')" id="side-config" class="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">
<span class="material-symbols-outlined">settings</span><span>Configuration</span>
</a>
</div>
<div class="px-3 mt-auto">
<button onclick="openAddHost()" class="w-full bg-primary text-on-primary font-bold py-3 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all text-sm">
<span class="material-symbols-outlined text-sm">add</span> New Host
</button>
</div>
</aside>
<!-- ── Main Content ────────────────────────────────────── -->
<main class="lg:ml-56 pt-24 px-6 lg:px-8 pb-12 min-h-screen">
<!-- ════════ PAGE: DASHBOARD ════════ -->
<div id="page-dashboard">
<!-- Header -->
<header class="mb-10 flex flex-col md:flex-row justify-between md:items-end gap-4">
<div>
<h1 class="text-3xl font-extrabold font-headline tracking-tight text-white">Vault Overview</h1>
<p class="text-on-surface-variant mt-1">Operational command for Borgmatic backup infrastructure.</p>
</div>
<div class="flex gap-3">
<div class="flex items-center gap-2 bg-surface-container-low px-4 py-2 rounded-lg text-sm">
<span class="text-on-surface-variant">Last Scan:</span>
<span class="text-primary font-semibold" id="lastScan"></span>
</div>
<button onclick="loadAll()" class="bg-surface-container-highest px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 hover:bg-surface-bright transition-colors">
<span class="material-symbols-outlined text-sm">refresh</span> Refresh
</button>
</div>
</header>
<main class="app-shell">
<section id="page-dashboard" class="page" aria-labelledby="dashboard-title">
<header class="page-header">
<div>
<p class="eyebrow">Borgmatic-Infrastruktur</p>
<h1 id="dashboard-title">Backup-Übersicht</h1>
<p class="lede">Der aktuelle Stand aller gemeldeten Sicherungen.</p>
</div>
<div class="scan-state">
<span>Letzte Aktualisierung</span>
<time id="lastScan"></time>
</div>
</header>
<section class="metric-strip" id="metricCards" aria-label="Zusammenfassung">
<article class="metric">
<span class="metric-label">Hosts in Ordnung</span>
<strong id="mOk"></strong>
</article>
<article class="metric">
<span class="metric-label">Heute gesichert</span>
<strong id="mSize"></strong>
</article>
<article class="metric">
<span class="metric-label">Letzte Sicherung</span>
<strong id="mLatest"></strong>
<small id="mLatestHost"></small>
</article>
<article class="metric metric--issue" id="mWarnCard">
<span class="metric-label">Auffälligkeiten</span>
<strong id="mWarn">0</strong>
<button type="button" class="text-button" onclick="showPage('alerts')">Meldungen öffnen</button>
</article>
</section>
<div class="dashboard-grid">
<section class="panel panel--chart" aria-labelledby="volume-title">
<header class="panel-header">
<div>
<h2 id="volume-title">Gesichertes Datenvolumen</h2>
<p>Summe der letzten 30 Tage, über die zehn zuerst gemeldeten Hosts.</p>
</div>
</header>
<div class="chart" id="volumeChart">
<svg id="chartSvg" role="img" aria-label="Backup-Volumen der letzten 30 Tage" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
</div>
</section>
<section class="panel panel--hosts" aria-labelledby="host-status-title">
<header class="panel-header">
<h2 id="host-status-title">Host-Status</h2>
</header>
<div id="clusterList" class="cluster-list"></div>
</section>
<!-- Metric Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 lg:gap-6 mb-8" id="metricCards">
<div class="bg-surface-container-low p-6 rounded-xl relative overflow-hidden">
<div class="absolute top-0 right-0 w-24 h-24 bg-secondary/5 rounded-full -mr-8 -mt-8 blur-2xl"></div>
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-secondary/10 rounded-lg"><span class="material-symbols-outlined text-secondary" style="font-variation-settings:'FILL' 1">check_circle</span></div>
</div>
<section class="panel" aria-labelledby="stream-title">
<header class="panel-header panel-header--split">
<h2 id="stream-title">Letzte Meldungen</h2>
<span class="quiet-note">automatisch alle 30 Sekunden</span>
</header>
<div id="liveStream" class="event-list" aria-live="polite"></div>
</section>
</section>
<section id="page-alerts" class="page hidden" aria-labelledby="alerts-title">
<header class="page-header">
<div>
<p class="eyebrow">Prüfbedarf</p>
<h1 id="alerts-title">Meldungen</h1>
<p class="lede">Fehlgeschlagene oder überfällige Sicherungen.</p>
</div>
<dl class="inline-counts">
<div><dt>Fehler</dt><dd id="aCrit">0</dd></div>
<div><dt>Überfällig</dt><dd id="aStale">0</dd></div>
</dl>
</header>
<div id="alertList" class="alert-list">
<p class="empty-state">Keine offenen Meldungen.</p>
<div class="text-4xl font-extrabold font-headline text-white mb-1" id="mOk"></div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Hosts OK</div>
</div>
<div class="bg-surface-container-low p-6 rounded-xl relative overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-primary/10 rounded-lg"><span class="material-symbols-outlined text-primary" style="font-variation-settings:'FILL' 1">database</span></div>
</div>
</section>
<section id="page-hosts" class="page hidden" aria-labelledby="hosts-title">
<header class="page-header">
<div>
<p class="eyebrow">Inventar</p>
<h1 id="hosts-title">Backup-Hosts</h1>
<p class="lede">Alle registrierten Endpunkte und ihre letzten Sicherungen.</p>
</div>
<button type="button" class="button button--primary" onclick="openAddHost()">Host hinzufügen</button>
</header>
<div id="hostGrid" class="host-grid"></div>
</section>
<section id="page-config" class="page hidden" aria-labelledby="config-title">
<header class="page-header">
<div>
<p class="eyebrow">Schnittstellen</p>
<h1 id="config-title">Einstellungen</h1>
<p class="lede">Die Endpunkte, über die Sentinel Daten annimmt und ausliefert.</p>
</div>
</header>
<dl class="settings-list">
<div>
<dt>Backup-Meldungen</dt>
<dd><code>POST /api/push</code></dd>
</div>
<div>
<dt>Prometheus</dt>
<dd><code>GET /metrics</code></dd>
</div>
<div>
<dt>API-Schlüssel</dt>
<dd>{{ 'Aktiviert (Umgebungsvariable API_KEY)' if api_key_required else 'Deaktiviert' }}</dd>
</div>
</dl>
</section>
</main>
<div id="drawerBg" class="backdrop is-hidden" onclick="closeDrawer()"></div>
<aside id="drawer" class="drawer is-closed" aria-labelledby="drawerTitle" aria-hidden="true">
<header class="overlay-header">
<div>
<span class="eyebrow">Host-Details</span>
<h2 id="drawerTitle">Host</h2>
<div class="text-4xl font-extrabold font-headline text-white mb-1" id="mSize"></div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Today Backed Up</div>
</div>
<div class="bg-surface-container-low p-6 rounded-xl relative overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-secondary/10 rounded-lg pulse-ok"><span class="material-symbols-outlined text-secondary" style="font-variation-settings:'FILL' 1">bolt</span></div>
<div class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full bg-secondary"></div><span class="text-xs font-bold text-secondary">LIVE</span></div>
</div>
<button type="button" class="icon-button" onclick="closeDrawer()" aria-label="Details schließen">×</button>
</header>
<div id="drawerBody" class="drawer-body"></div>
</aside>
<div id="modalBg" class="backdrop is-hidden" onclick="closeModal()"></div>
<section id="modal" class="modal is-closed" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-hidden="true">
<header class="overlay-header">
<h2 id="modalTitle">Host hinzufügen</h2>
<button type="button" class="icon-button" onclick="closeModal()" aria-label="Dialog schließen">×</button>
</header>
<form id="hostForm" onsubmit="saveHost(event)" class="form-stack">
<input type="hidden" id="formMode" value="add">
<label>
<span>Hostname</span>
<input type="text" id="formName" required placeholder="zum Beispiel arrapps">
</label>
<label>
<span>Uptime-Kuma Push-URL</span>
<input type="url" id="formKumaUrl" placeholder="https://status.example.com/api/push/…">
</label>
<label class="checkbox-row">
<input type="checkbox" id="formEnabled" checked>
<span>Host überwachen</span>
</label>
<div class="form-actions">
<button type="button" class="button" onclick="closeModal()">Abbrechen</button>
<button type="submit" class="button button--primary">Speichern</button>
<div class="text-2xl font-bold font-headline text-white mb-1" id="mLatest"></div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Latest Backup</div>
<div class="text-[10px] mt-2 font-mono text-slate-500" id="mLatestHost"></div>
</div>
<div class="bg-surface-container-low p-6 rounded-xl" id="mWarnCard">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-error/10 rounded-lg"><span class="material-symbols-outlined text-error" style="font-variation-settings:'FILL' 1">report_problem</span></div>
</div>
</form>
</section>
<div class="text-4xl font-extrabold font-headline text-error mb-1" id="mWarn">0</div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Issues</div>
<button onclick="showPage('alerts')" class="mt-3 text-xs font-bold text-error flex items-center gap-1 hover:underline">
View alerts <span class="material-symbols-outlined text-xs">arrow_forward</span>
</button>
</div>
</div>
<div id="toasts" class="toast-stack" aria-live="polite"></div>
<script src="/static/js/app.js"></script>
<!-- Chart + Clusters -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Volume Trend Chart -->
<div class="lg:col-span-2 bg-surface-container-low rounded-xl p-6 lg:p-8">
<div class="flex justify-between items-center mb-8">
<div>
<h3 class="text-lg font-bold font-headline text-white">Backup Volume Trends</h3>
<p class="text-sm text-on-surface-variant">Storage growth across all hosts (Last 30 Days)</p>
</div>
</div>
<div class="h-48 w-full relative" id="volumeChart">
<svg class="w-full h-full" preserveAspectRatio="none" id="chartSvg"></svg>
</div>
</div>
<!-- Host Clusters -->
<div class="bg-surface-container-low rounded-xl p-6 lg:p-8">
<h3 class="text-lg font-bold font-headline text-white mb-6">Host Status</h3>
<div class="space-y-3 max-h-[300px] overflow-y-auto" id="clusterList"></div>
</div>
</div>
<!-- Live Stream -->
<div class="bg-surface-container-low rounded-xl p-6 lg:p-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold font-headline text-white">Live Backup Stream</h3>
<span class="text-xs text-on-surface-variant font-mono">Auto-refresh: 30s</span>
</div>
<div class="space-y-0" id="liveStream"></div>
</div>
</div>
<!-- ════════ PAGE: ALERTS ════════ -->
<div id="page-alerts" class="hidden">
<header class="mb-10 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h1 class="text-3xl font-extrabold tracking-tighter font-headline text-white">Alert Center</h1>
<p class="text-on-surface-variant max-w-lg">Real-time surveillance of the backup infrastructure.</p>
</div>
<div class="flex gap-3">
<div class="bg-surface-container-low px-6 py-4 rounded-xl flex flex-col gap-1 border-b-2 border-error">
<span class="text-error font-bold text-2xl" id="aCrit">0</span>
<span class="text-[10px] uppercase tracking-widest text-on-surface-variant font-bold">Errors</span>
</div>
<div class="bg-surface-container-low px-6 py-4 rounded-xl flex flex-col gap-1 border-b-2 border-tertiary">
<span class="text-tertiary font-bold text-2xl" id="aStale">0</span>
<span class="text-[10px] uppercase tracking-widest text-on-surface-variant font-bold">Stale</span>
</div>
</div>
</header>
<div class="space-y-4" id="alertList">
<div class="text-center py-12 text-on-surface-variant">No active alerts all systems operational ✓</div>
</div>
</div>
<!-- ════════ PAGE: HOSTS ════════ -->
<div id="page-hosts" class="hidden">
<header class="mb-10 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h1 class="text-3xl font-extrabold tracking-tighter font-headline text-white">Backup Hosts</h1>
<p class="text-on-surface-variant">Manage and monitor all registered backup endpoints.</p>
</div>
<button onclick="openAddHost()" class="bg-primary text-on-primary font-bold py-3 px-6 rounded-xl flex items-center gap-2 shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all text-sm">
<span class="material-symbols-outlined text-sm">add</span> Add Host
</button>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4" id="hostGrid"></div>
</div>
<!-- ════════ PAGE: CONFIG ════════ -->
<div id="page-config" class="hidden">
<header class="mb-10">
<h1 class="text-3xl font-extrabold tracking-tighter font-headline text-white">Configuration</h1>
<p class="text-on-surface-variant mt-1">API endpoint and integration settings.</p>
</header>
<div class="bg-surface-container-low rounded-xl p-8 max-w-2xl space-y-6">
<div>
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-wider mb-3">Push Endpoint</h3>
<code class="block bg-surface-dim px-4 py-3 rounded-lg text-primary text-sm font-mono">POST /api/push</code>
</div>
<div>
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-wider mb-3">Prometheus Metrics</h3>
<code class="block bg-surface-dim px-4 py-3 rounded-lg text-primary text-sm font-mono">GET /metrics</code>
</div>
<div>
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-wider mb-3">API Key</h3>
<p class="text-sm text-on-surface-variant">{{ 'Enabled set via API_KEY env var' if api_key_required else 'Disabled all endpoints open' }}</p>
</div>
</div>
</div>
</main>
<!-- ── Host Detail Drawer ──────────────────────────────── -->
<div id="drawerBg" onclick="closeDrawer()" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 opacity-0 pointer-events-none transition-opacity duration-300"></div>
<aside id="drawer" class="fixed top-0 right-0 bottom-0 w-[500px] max-w-[90vw] bg-surface-container-lowest z-[51] shadow-2xl transform translate-x-full transition-transform duration-300 flex flex-col">
<div class="flex justify-between items-center px-6 py-5 border-b border-outline-variant/10">
<h2 class="text-lg font-bold font-headline text-white" id="drawerTitle">Host</h2>
<button onclick="closeDrawer()" class="material-symbols-outlined text-slate-400 hover:text-white p-1 rounded-lg hover:bg-surface-container-high transition-colors">close</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-6" id="drawerBody"></div>
</aside>
<!-- ── Add/Edit Modal ──────────────────────────────────── -->
<div id="modalBg" onclick="closeModal()" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] opacity-0 pointer-events-none transition-opacity duration-300"></div>
<div id="modal" class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[440px] max-w-[90vw] bg-surface-container-lowest rounded-xl z-[61] shadow-2xl scale-95 opacity-0 pointer-events-none transition-all duration-300">
<div class="flex justify-between items-center px-6 py-5 border-b border-outline-variant/10">
<h2 class="text-lg font-bold font-headline text-white" id="modalTitle">Add Host</h2>
<button onclick="closeModal()" class="material-symbols-outlined text-slate-400 hover:text-white">close</button>
</div>
<form id="hostForm" onsubmit="saveHost(event)" class="p-6 space-y-4">
<input type="hidden" id="formMode" value="add">
<div>
<label class="block text-xs text-on-surface-variant uppercase tracking-wider font-bold mb-2">Hostname</label>
<input type="text" id="formName" required class="w-full bg-surface-container-highest border-none rounded-lg px-4 py-3 text-on-surface text-sm focus:ring-2 focus:ring-primary/30" placeholder="e.g. arrapps">
</div>
<div>
<label class="block text-xs text-on-surface-variant uppercase tracking-wider font-bold mb-2">Uptime Kuma Push URL</label>
<input type="url" id="formKumaUrl" class="w-full bg-surface-container-highest border-none rounded-lg px-4 py-3 text-on-surface text-sm focus:ring-2 focus:ring-primary/30" placeholder="https://status.example.com/api/push/...">
</div>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" id="formEnabled" checked class="rounded bg-surface-container-highest border-none text-primary focus:ring-primary/30">
<span class="text-sm text-on-surface">Enabled</span>
</label>
<div class="flex justify-end gap-3 pt-4">
<button type="button" onclick="closeModal()" class="px-5 py-2.5 rounded-lg text-sm font-bold text-on-surface-variant hover:bg-surface-container-high transition-colors">Cancel</button>
<button type="submit" class="bg-primary text-on-primary px-6 py-2.5 rounded-lg text-sm font-bold hover:brightness-110 active:scale-95 transition-all">Save</button>
</div>
</form>
</div>
<!-- ── Toast ───────────────────────────────────────────── -->
<div id="toasts" class="fixed bottom-6 right-6 z-[70] flex flex-col gap-2"></div>
<script src="/static/js/app.js"></script>
</body>
</html>

View file

@ -1,77 +0,0 @@
import re
import subprocess
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
HTML = (ROOT / "templates" / "index.html").read_text(encoding="utf-8")
JS = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "style.css").read_text(encoding="utf-8")
class FrontendContractTests(unittest.TestCase):
def test_stable_dom_ids_exist_once(self):
required = {
"nav-dashboard", "nav-alerts", "nav-hosts", "nav-config",
"sysStatus", "lastScan", "mOk", "mSize", "mLatest",
"mLatestHost", "mWarn", "mWarnCard", "clusterList",
"liveStream", "chartSvg", "hostGrid", "alertList",
"drawer", "drawerBg", "drawerTitle", "drawerBody",
"modal", "modalBg", "hostForm", "formMode", "formName",
"formKumaUrl", "formEnabled", "toasts",
}
for element_id in required:
count = len(re.findall(rf'id=["\']{re.escape(element_id)}["\']', HTML))
self.assertEqual(count, 1, f"#{element_id} must exist exactly once")
def test_uses_local_styles_without_runtime_css_framework(self):
self.assertIn('/static/css/style.css', HTML)
self.assertNotIn('cdn.tailwindcss.com', HTML)
self.assertNotIn('Material+Symbols', HTML)
self.assertNotIn('backdrop-blur', HTML)
self.assertNotIn('pulse-', HTML)
def test_monitor_surface_has_semantic_landmarks_and_accessible_actions(self):
self.assertRegex(HTML, r'<nav[^>]+aria-label="Hauptnavigation"')
self.assertIn('<main', HTML)
self.assertIn('aria-label="Daten aktualisieren"', HTML)
self.assertIn('aria-label="Host hinzufügen"', HTML)
self.assertIn('aria-live="polite"', HTML)
def test_copy_is_plain_german(self):
expected = [
'Backup-Übersicht', 'Letzte Aktualisierung', 'Heute gesichert',
'Letzte Sicherung', 'Auffälligkeiten', 'Letzte Meldungen',
'Backup-Hosts', 'Einstellungen',
]
for text in expected:
self.assertIn(text, HTML)
forbidden = ['Operational command', 'Real-time surveillance', 'LIVE', 'Vault Overview']
for text in forbidden:
self.assertNotIn(text, HTML)
def test_css_contains_responsive_and_motion_contracts(self):
self.assertIn('@media (max-width: 720px)', CSS)
self.assertIn('@media (prefers-reduced-motion: reduce)', CSS)
self.assertIn(':focus-visible', CSS)
self.assertIn('--status-ok', CSS)
def test_javascript_uses_semantic_status_classes_and_german_labels(self):
self.assertIn('status--ok', JS)
self.assertIn('Betriebsbereit', JS)
self.assertIn('vor ', JS)
self.assertNotIn('material-symbols-outlined', JS)
self.assertNotIn('bg-${color}', JS)
def test_javascript_syntax(self):
result = subprocess.run(
["node", "--check", str(ROOT / "static" / "js" / "app.js")],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()