feat: Complete UI redesign – The Sentinel Design System
- Tailwind CSS with Material 3 color tokens - Manrope + Inter typography (editorial + technical) - Glassmorphism nav bar, no-line card boundaries - Multi-page SPA: Dashboard, Alert Center, Backup Hosts, Config - Dashboard: Bento metric cards, SVG volume trend chart, host clusters, live backup stream - Alert Center: Severity-based alert list with pulse animations - Host Grid: Status-colored cards with 7-day stats - Detail Drawer: 30-day calendar heatmap, volume chart, history table - Sidebar navigation with active state indicators - Responsive: sidebar collapses on mobile - Dark theme based on Sentinel 'Digital Vault' aesthetic
This commit is contained in:
parent
c7158acc96
commit
fd21d86f29
3 changed files with 540 additions and 823 deletions
|
|
@ -1,486 +1 @@
|
|||
/* ── Backup Monitor – Dark Theme UI ─────────────────────────── */
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg-card: #1a1d27;
|
||||
--bg-card-hover: #22263a;
|
||||
--bg-drawer: #161922;
|
||||
--border: #2a2e3d;
|
||||
--text: #e4e6f0;
|
||||
--text-dim: #8b8fa3;
|
||||
--text-muted: #5c6078;
|
||||
--accent: #6c5ce7;
|
||||
--accent-glow: rgba(108, 92, 231, 0.3);
|
||||
--ok: #00d68f;
|
||||
--ok-bg: rgba(0, 214, 143, 0.1);
|
||||
--ok-glow: rgba(0, 214, 143, 0.25);
|
||||
--warn: #ffaa00;
|
||||
--warn-bg: rgba(255, 170, 0, 0.1);
|
||||
--warn-glow: rgba(255, 170, 0, 0.25);
|
||||
--error: #ff3d71;
|
||||
--error-bg: rgba(255, 61, 113, 0.1);
|
||||
--error-glow: rgba(255, 61, 113, 0.25);
|
||||
--disabled: #4a4e69;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────── */
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.logo { font-size: 28px; }
|
||||
h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.subtitle { color: var(--text-dim); font-size: 13px; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
.meta-text { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.pulse-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 8px var(--ok-glow);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.pulse-dot.warn { background: var(--warn); box-shadow: 0 0 8px var(--warn-glow); }
|
||||
.pulse-dot.error { background: var(--error); box-shadow: 0 0 8px var(--error-glow); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.btn:hover { background: var(--bg-card-hover); border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: #7c6cf0; box-shadow: 0 0 20px var(--accent-glow); }
|
||||
.btn-icon { padding: 8px 10px; font-size: 16px; line-height: 1; }
|
||||
.btn-danger { color: var(--error); }
|
||||
.btn-danger:hover { background: var(--error-bg); border-color: var(--error); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* ── Summary Grid ──────────────────────────────────────────── */
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
text-align: center;
|
||||
padding: 20px 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.summary-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
.card-ok::before { background: var(--ok); box-shadow: 0 0 12px var(--ok-glow); }
|
||||
.card-warn::before { background: var(--warn); box-shadow: 0 0 12px var(--warn-glow); }
|
||||
.card-error::before { background: var(--error); box-shadow: 0 0 12px var(--error-glow); }
|
||||
|
||||
.card-value { font-size: 32px; font-weight: 800; letter-spacing: -1px; }
|
||||
.card-ok .card-value { color: var(--ok); }
|
||||
.card-warn .card-value { color: var(--warn); }
|
||||
.card-error .card-value { color: var(--error); }
|
||||
.card-label { color: var(--text-dim); font-size: 12px; margin-top: 4px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
|
||||
/* ── Cards ─────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.card:hover { border-color: var(--accent); box-shadow: var(--shadow); }
|
||||
|
||||
/* ── Host Grid ─────────────────────────────────────────────── */
|
||||
|
||||
.host-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.host-card {
|
||||
cursor: pointer;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.host-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; bottom: 0; left: 0;
|
||||
width: 4px;
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
}
|
||||
.host-card[data-status="ok"]::after { background: var(--ok); box-shadow: 0 0 8px var(--ok-glow); }
|
||||
.host-card[data-status="stale"]::after { background: var(--warn); box-shadow: 0 0 8px var(--warn-glow); }
|
||||
.host-card[data-status="error"]::after { background: var(--error); box-shadow: 0 0 8px var(--error-glow); }
|
||||
.host-card[data-status="disabled"]::after { background: var(--disabled); }
|
||||
.host-card[data-status="disabled"] { opacity: 0.5; }
|
||||
|
||||
.host-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.host-name { font-size: 16px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.host-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge-ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.badge-stale { background: var(--warn-bg); color: var(--warn); }
|
||||
.badge-error { background: var(--error-bg); color: var(--error); }
|
||||
.badge-disabled { background: rgba(74,78,105,0.2); color: var(--disabled); }
|
||||
|
||||
.host-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.meta-item { display: flex; flex-direction: column; }
|
||||
.meta-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.meta-value { font-size: 14px; font-weight: 600; margin-top: 2px; }
|
||||
|
||||
.host-minibar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-top: 14px;
|
||||
height: 24px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.minibar-day {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
min-height: 3px;
|
||||
transition: all var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
.minibar-day:hover { opacity: 0.8; transform: scaleY(1.15); }
|
||||
.minibar-day.ok { background: var(--ok); }
|
||||
.minibar-day.error { background: var(--error); }
|
||||
.minibar-day.empty { background: var(--border); min-height: 3px; }
|
||||
|
||||
/* ── Drawer ────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.drawer-overlay.open { opacity: 1; pointer-events: all; }
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0; right: -520px; bottom: 0;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
background: var(--bg-drawer);
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: 201;
|
||||
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -8px 0 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
.drawer.open { right: 0; }
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.drawer-header h2 { font-size: 18px; font-weight: 700; }
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ── Drawer: Calendar Heatmap ──────────────────────────────── */
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.cal-day {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
transition: all var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
.cal-day:hover { transform: scale(1.15); z-index: 1; }
|
||||
.cal-day.empty { background: var(--bg-card); }
|
||||
.cal-day.ok { background: var(--ok); color: #fff; font-weight: 700; }
|
||||
.cal-day.error { background: var(--error); color: #fff; font-weight: 700; }
|
||||
.cal-day.future { background: transparent; border: 1px dashed var(--border); }
|
||||
|
||||
.cal-day .tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--text);
|
||||
}
|
||||
.cal-day:hover .tooltip { display: block; }
|
||||
|
||||
/* ── Drawer: History Table ─────────────────────────────────── */
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.history-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.history-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.history-table tr:hover td { background: var(--bg-card-hover); }
|
||||
|
||||
/* ── Drawer: Stats Row ─────────────────────────────────────── */
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.stat-box {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value { font-size: 22px; font-weight: 800; color: var(--accent); }
|
||||
.stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
/* ── Drawer: Size Chart ────────────────────────────────────── */
|
||||
|
||||
.size-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 80px;
|
||||
margin: 16px 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.size-bar {
|
||||
flex: 1;
|
||||
background: var(--accent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
transition: all var(--transition);
|
||||
position: relative;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.size-bar:hover { opacity: 1; }
|
||||
.size-bar .tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--text);
|
||||
}
|
||||
.size-bar:hover .tooltip { display: block; }
|
||||
|
||||
/* ── Modal ─────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 300;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.modal-overlay.open { opacity: 1; pointer-events: all; }
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
background: var(--bg-drawer);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0;
|
||||
width: 440px;
|
||||
max-width: 90vw;
|
||||
z-index: 301;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal.open { opacity: 1; pointer-events: all; transform: translate(-50%, -50%) scale(1); }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 { font-size: 16px; font-weight: 700; }
|
||||
|
||||
.modal form { padding: 24px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.form-group input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||
.checkbox-label { display: flex !important; align-items: center; gap: 8px; cursor: pointer; font-size: 14px !important; text-transform: none !important; letter-spacing: 0 !important; color: var(--text) !important; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
|
||||
|
||||
/* ── Toast ─────────────────────────────────────────────────── */
|
||||
|
||||
.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 400; display: flex; flex-direction: column; gap: 8px; }
|
||||
.toast {
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
box-shadow: var(--shadow);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.toast.success { border-left: 3px solid var(--ok); }
|
||||
.toast.error { border-left: 3px solid var(--error); }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Section Headers ───────────────────────────────────────── */
|
||||
|
||||
.section-header {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 20px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Drawer Actions ────────────────────────────────────────── */
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header { flex-direction: column; gap: 12px; }
|
||||
.summary-grid { grid-template-columns: repeat(3, 1fr); padding: 16px; }
|
||||
.host-grid { grid-template-columns: 1fr; padding: 0 16px 16px; }
|
||||
.drawer { width: 100%; max-width: 100vw; }
|
||||
.stats-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* Sentinel Design System – all styles via Tailwind CSS */
|
||||
|
|
|
|||
497
static/js/app.js
497
static/js/app.js
|
|
@ -1,13 +1,14 @@
|
|||
/* ── Backup Monitor – Frontend Logic ────────────────────────── */
|
||||
/* ── The Sentinel – Backup Monitor Frontend ────────────────── */
|
||||
|
||||
const API = '';
|
||||
let refreshTimer;
|
||||
let apiKey = localStorage.getItem('bm_api_key') || '';
|
||||
let allHosts = [];
|
||||
let currentPage = 'dashboard';
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────
|
||||
// ── Init ──────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAll();
|
||||
refreshTimer = setInterval(loadAll, 30000);
|
||||
setInterval(loadAll, 30000);
|
||||
});
|
||||
|
||||
function authHeaders() {
|
||||
|
|
@ -22,232 +23,273 @@ async function apiFetch(url, opts = {}) {
|
|||
const r = await fetch(url, opts);
|
||||
if (r.status === 401) {
|
||||
const key = prompt('🔑 API-Key eingeben:');
|
||||
if (key) {
|
||||
apiKey = key;
|
||||
localStorage.setItem('bm_api_key', key);
|
||||
opts.headers['X-API-Key'] = key;
|
||||
return fetch(url, opts);
|
||||
}
|
||||
if (key) { apiKey = key; localStorage.setItem('bm_api_key', key); opts.headers['X-API-Key'] = key; return fetch(url, opts); }
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadSummary(), loadHosts()]);
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE');
|
||||
const [sumR, hostsR] = await Promise.all([fetch(`${API}/api/summary`), fetch(`${API}/api/hosts`)]);
|
||||
const sum = await sumR.json();
|
||||
allHosts = await hostsR.json();
|
||||
renderDashboard(sum);
|
||||
renderAlerts();
|
||||
renderHostGrid();
|
||||
document.getElementById('lastScan').textContent = new Date().toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'});
|
||||
// System status indicator
|
||||
const ss = document.getElementById('sysStatus');
|
||||
if (sum.error > 0) { ss.innerHTML = '<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>'; }
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────
|
||||
async function loadSummary() {
|
||||
const r = await fetch(`${API}/api/summary`);
|
||||
const d = await r.json();
|
||||
document.getElementById('valTotal').textContent = d.total_hosts;
|
||||
document.getElementById('valOk').textContent = d.ok;
|
||||
document.getElementById('valStale').textContent = d.stale;
|
||||
document.getElementById('valError').textContent = d.error;
|
||||
document.getElementById('valToday').textContent = d.today_backups;
|
||||
document.getElementById('valSize').textContent = fmtBytes(d.today_size);
|
||||
// ── Dashboard ─────────────────────────────────────────────
|
||||
function renderDashboard(sum) {
|
||||
document.getElementById('mOk').textContent = `${sum.ok}/${sum.total_hosts}`;
|
||||
document.getElementById('mSize').textContent = fmtBytes(sum.today_size);
|
||||
document.getElementById('mWarn').textContent = sum.error + sum.stale;
|
||||
const wc = document.getElementById('mWarnCard');
|
||||
wc.className = 'bg-surface-container-low p-6 rounded-xl' + ((sum.error + sum.stale > 0) ? ' border border-error/20' : '');
|
||||
|
||||
const pulse = document.getElementById('globalPulse');
|
||||
pulse.className = 'pulse-dot';
|
||||
if (d.error > 0) pulse.classList.add('error');
|
||||
else if (d.stale > 0) pulse.classList.add('warn');
|
||||
// Latest backup
|
||||
const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup));
|
||||
if (sorted.length) {
|
||||
document.getElementById('mLatest').textContent = sorted[0].last_status === 'ok' ? 'Success' : 'Error';
|
||||
document.getElementById('mLatestHost').textContent = sorted[0].name;
|
||||
}
|
||||
|
||||
// Cluster list
|
||||
const cl = document.getElementById('clusterList');
|
||||
const groups = { ok: [], stale: [], error: [], disabled: [] };
|
||||
allHosts.forEach(h => groups[h.status]?.push(h));
|
||||
let html = '';
|
||||
if (groups.error.length) { html += clusterGroup('ERRORS', groups.error, 'error'); }
|
||||
if (groups.stale.length) { html += clusterGroup('STALE', groups.stale, 'tertiary'); }
|
||||
if (groups.ok.length) { html += clusterGroup('OPERATIONAL', groups.ok, 'secondary'); }
|
||||
if (groups.disabled.length) { html += clusterGroup('DISABLED', groups.disabled, 'outline'); }
|
||||
cl.innerHTML = html;
|
||||
|
||||
// Live stream
|
||||
renderLiveStream();
|
||||
loadVolumeChart();
|
||||
}
|
||||
|
||||
// ── Host Grid ─────────────────────────────────────────────────
|
||||
async function loadHosts() {
|
||||
const r = await fetch(`${API}/api/hosts`);
|
||||
const hosts = await r.json();
|
||||
const grid = document.getElementById('hostGrid');
|
||||
|
||||
grid.innerHTML = hosts.map(h => `
|
||||
<div class="card host-card" data-status="${h.status}" onclick="openHost('${h.name}')">
|
||||
<div class="host-header">
|
||||
<span class="host-name">${h.name}</span>
|
||||
<span class="host-badge badge-${h.status}">${statusLabel(h.status)}</span>
|
||||
</div>
|
||||
<div class="host-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Letztes Backup</span>
|
||||
<span class="meta-value">${h.last_backup ? timeAgo(h.last_backup) : 'Nie'}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">7-Tage</span>
|
||||
<span class="meta-value">${h.backup_count_7d} Backups</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Ø Dauer</span>
|
||||
<span class="meta-value">${fmtDuration(h.avg_duration_7d)}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">7-Tage Volumen</span>
|
||||
<span class="meta-value">${fmtBytes(h.total_size_7d)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-minibar" id="mini-${h.name}"></div>
|
||||
function clusterGroup(label, hosts, color) {
|
||||
return `
|
||||
<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('');
|
||||
|
||||
// Load minibars
|
||||
for (const h of hosts) {
|
||||
loadMinibar(h.name);
|
||||
}
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadMinibar(host) {
|
||||
const r = await fetch(`${API}/api/calendar/${host}?days=14`);
|
||||
const cal = await r.json();
|
||||
const el = document.getElementById(`mini-${host}`);
|
||||
if (!el) return;
|
||||
|
||||
const days = [];
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
days.push({ key, data: cal[key] || null });
|
||||
}
|
||||
|
||||
const maxSize = Math.max(...days.map(d => d.data?.total_size || 0), 1);
|
||||
el.innerHTML = days.map(d => {
|
||||
if (!d.data) return `<div class="minibar-day empty" title="${d.key}: Kein Backup"></div>`;
|
||||
const h = Math.max(15, (d.data.total_size / maxSize) * 100);
|
||||
const cls = d.data.has_error ? 'error' : 'ok';
|
||||
return `<div class="minibar-day ${cls}" style="height:${h}%" title="${d.key}: ${d.data.count}x, ${fmtBytes(d.data.total_size)}"></div>`;
|
||||
function renderLiveStream() {
|
||||
const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup)).slice(0, 8);
|
||||
const ls = document.getElementById('liveStream');
|
||||
ls.innerHTML = sorted.map(h => {
|
||||
const t = new Date(h.last_backup).toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
const isErr = h.last_status !== 'ok';
|
||||
return `
|
||||
<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('');
|
||||
}
|
||||
|
||||
// ── Host Detail Drawer ────────────────────────────────────────
|
||||
async function loadVolumeChart() {
|
||||
// Aggregate daily totals from all hosts
|
||||
const days = [];
|
||||
for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(d.toISOString().split('T')[0]); }
|
||||
const dailyTotals = {};
|
||||
days.forEach(d => dailyTotals[d] = 0);
|
||||
|
||||
// Fetch calendar for top hosts (limit to avoid too many requests)
|
||||
const topHosts = allHosts.slice(0, 10);
|
||||
const cals = await Promise.all(topHosts.map(h => fetch(`${API}/api/calendar/${h.name}?days=30`).then(r => r.json())));
|
||||
cals.forEach(cal => { Object.entries(cal).forEach(([day, data]) => { if (dailyTotals[day] !== undefined) dailyTotals[day] += data.total_size; }); });
|
||||
|
||||
const values = days.map(d => dailyTotals[d]);
|
||||
const max = Math.max(...values, 1);
|
||||
const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${100 - (v / max) * 80}`);
|
||||
const pathD = 'M' + points.join(' L');
|
||||
const fillD = pathD + ` L100,100 L0,100 Z`;
|
||||
|
||||
document.getElementById('chartSvg').innerHTML = `
|
||||
<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(h => h.status === 'error' || h.status === 'stale');
|
||||
document.getElementById('aCrit').textContent = String(allHosts.filter(h => h.status === 'error').length).padStart(2, '0');
|
||||
document.getElementById('aStale').textContent = String(allHosts.filter(h => h.status === 'stale').length).padStart(2, '0');
|
||||
|
||||
const al = document.getElementById('alertList');
|
||||
if (!issues.length) { al.innerHTML = '<div class="text-center py-16 text-on-surface-variant text-sm">No active alerts – all systems operational ✓</div>'; return; }
|
||||
|
||||
al.innerHTML = issues.map(h => {
|
||||
const isCrit = h.status === 'error';
|
||||
const color = isCrit ? 'error' : 'tertiary';
|
||||
const icon = isCrit ? 'error' : 'warning';
|
||||
const label = isCrit ? 'CRITICAL' : 'STALE';
|
||||
return `
|
||||
<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>
|
||||
<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(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) {
|
||||
document.getElementById('drawerTitle').textContent = name;
|
||||
document.getElementById('drawerOverlay').classList.add('open');
|
||||
document.getElementById('drawer').classList.add('open');
|
||||
|
||||
document.getElementById('drawerBg').classList.remove('opacity-0','pointer-events-none');
|
||||
document.getElementById('drawer').classList.remove('translate-x-full');
|
||||
const body = document.getElementById('drawerBody');
|
||||
body.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted)">Lade...</div>';
|
||||
body.innerHTML = '<div class="text-center py-12 text-on-surface-variant">Loading...</div>';
|
||||
|
||||
const [histR, calR, hostsR] = await Promise.all([
|
||||
fetch(`${API}/api/history/${name}?days=30`),
|
||||
fetch(`${API}/api/calendar/${name}?days=30`),
|
||||
fetch(`${API}/api/hosts`),
|
||||
]);
|
||||
const [histR, calR] = await Promise.all([fetch(`${API}/api/history/${name}?days=30`), fetch(`${API}/api/calendar/${name}?days=30`)]);
|
||||
const history = await histR.json();
|
||||
const calendar = await calR.json();
|
||||
const hosts = await hostsR.json();
|
||||
const host = hosts.find(h => h.name === name) || {};
|
||||
const host = allHosts.find(h => h.name === name) || {};
|
||||
|
||||
// Stats
|
||||
const totalSize = history.reduce((s, e) => s + e.original_size, 0);
|
||||
const avgDuration = history.length ? Math.round(history.reduce((s, e) => s + e.duration_sec, 0) / history.length) : 0;
|
||||
const successRate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0;
|
||||
const totalSize = history.reduce((s,e) => s + e.original_size, 0);
|
||||
const avgDur = history.length ? Math.round(history.reduce((s,e) => s + e.duration_sec, 0) / history.length) : 0;
|
||||
const rate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">${history.length}</div>
|
||||
<div class="stat-label">Backups (30d)</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">${successRate}%</div>
|
||||
<div class="stat-label">Erfolgsrate</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">${fmtDuration(avgDuration)}</div>
|
||||
<div class="stat-label">Ø Dauer</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<div class="section-header">Kalender (30 Tage)</div>
|
||||
<div class="calendar-grid">${buildCalendar(calendar)}</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>
|
||||
|
||||
<div class="section-header">Datenvolumen (30 Tage)</div>
|
||||
<div class="size-chart">${buildSizeChart(history)}</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>
|
||||
|
||||
<div class="section-header">Letzte Backups</div>
|
||||
<table class="history-table">
|
||||
<thead><tr><th>Datum</th><th>Status</th><th>Dauer</th><th>Größe</th><th>Dateien</th></tr></thead>
|
||||
<tbody>
|
||||
${history.slice(0, 20).map(e => `
|
||||
<tr>
|
||||
<td>${new Date(e.timestamp).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}</td>
|
||||
<td><span class="host-badge badge-${e.status === 'ok' ? 'ok' : 'error'}">${e.status}</span></td>
|
||||
<td>${fmtDuration(e.duration_sec)}</td>
|
||||
<td>${fmtBytes(e.original_size)}</td>
|
||||
<td>${e.nfiles_new ? `+${e.nfiles_new}` : '–'} ${e.nfiles_changed ? `/ ~${e.nfiles_changed}` : ''}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 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>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<button class="btn" onclick="openEditHost('${name}')">⚙ Bearbeiten</button>
|
||||
<button class="btn btn-danger" onclick="confirmDelete('${name}')">🗑 Löschen</button>
|
||||
</div>
|
||||
<!-- 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(cal) {
|
||||
const days = [];
|
||||
const now = new Date();
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const d = new Date(); d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
const data = cal[key];
|
||||
const dayNum = d.getDate();
|
||||
|
||||
if (!data) {
|
||||
days.push(`<div class="cal-day empty">${dayNum}<div class="tooltip">${key}<br>Kein Backup</div></div>`);
|
||||
} else {
|
||||
const cls = data.has_error ? 'error' : 'ok';
|
||||
days.push(`<div class="cal-day ${cls}">${dayNum}<div class="tooltip">${key}<br>${data.count}x Backup<br>${fmtBytes(data.total_size)}<br>Ø ${fmtDuration(data.avg_duration)}</div></div>`);
|
||||
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>`);
|
||||
}
|
||||
}
|
||||
// Future days to fill the row
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + i);
|
||||
days.push(`<div class="cal-day future">${d.getDate()}</div>`);
|
||||
}
|
||||
return days.join('');
|
||||
}
|
||||
|
||||
function buildSizeChart(history) {
|
||||
// Group by day, last 30 days
|
||||
const byDay = {};
|
||||
history.forEach(e => {
|
||||
const day = e.timestamp.split('T')[0];
|
||||
if (!byDay[day]) byDay[day] = 0;
|
||||
byDay[day] += e.original_size;
|
||||
});
|
||||
|
||||
history.forEach(e => { const d = e.timestamp.split('T')[0]; byDay[d] = (byDay[d]||0) + e.original_size; });
|
||||
const days = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
days.push({ key, size: byDay[key] || 0 });
|
||||
}
|
||||
|
||||
const maxSize = Math.max(...days.map(d => d.size), 1);
|
||||
for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate()-i); days.push({key:d.toISOString().split('T')[0], size: byDay[d.toISOString().split('T')[0]]||0}); }
|
||||
const max = Math.max(...days.map(d=>d.size), 1);
|
||||
return days.map(d => {
|
||||
const h = d.size ? Math.max(4, (d.size / maxSize) * 100) : 2;
|
||||
const opacity = d.size ? '' : 'opacity:0.2;';
|
||||
return `<div class="size-bar" style="height:${h}%;${opacity}"><div class="tooltip">${d.key}<br>${fmtBytes(d.size)}</div></div>`;
|
||||
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() {
|
||||
document.getElementById('drawerOverlay').classList.remove('open');
|
||||
document.getElementById('drawer').classList.remove('open');
|
||||
document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none');
|
||||
document.getElementById('drawer').classList.add('translate-x-full');
|
||||
}
|
||||
|
||||
// ── Add/Edit Host Modal ───────────────────────────────────────
|
||||
// ── Modal ─────────────────────────────────────────────────
|
||||
function openAddHost() {
|
||||
document.getElementById('modalTitle').textContent = 'Host hinzufügen';
|
||||
document.getElementById('modalTitle').textContent = 'Add Host';
|
||||
document.getElementById('formMode').value = 'add';
|
||||
document.getElementById('formName').value = '';
|
||||
document.getElementById('formName').disabled = false;
|
||||
document.getElementById('formName').value = ''; document.getElementById('formName').disabled = false;
|
||||
document.getElementById('formKumaUrl').value = '';
|
||||
document.getElementById('formEnabled').checked = true;
|
||||
openModal();
|
||||
|
|
@ -255,15 +297,10 @@ function openAddHost() {
|
|||
|
||||
async function openEditHost(name) {
|
||||
closeDrawer();
|
||||
const r = await fetch(`${API}/api/hosts`);
|
||||
const hosts = await r.json();
|
||||
const h = hosts.find(x => x.name === name);
|
||||
if (!h) return;
|
||||
|
||||
document.getElementById('modalTitle').textContent = `${name} bearbeiten`;
|
||||
const h = allHosts.find(x => x.name === name); if (!h) return;
|
||||
document.getElementById('modalTitle').textContent = `Edit: ${name}`;
|
||||
document.getElementById('formMode').value = 'edit';
|
||||
document.getElementById('formName').value = h.name;
|
||||
document.getElementById('formName').disabled = true;
|
||||
document.getElementById('formName').value = h.name; document.getElementById('formName').disabled = true;
|
||||
document.getElementById('formKumaUrl').value = h.kuma_push_url || '';
|
||||
document.getElementById('formEnabled').checked = h.enabled;
|
||||
openModal();
|
||||
|
|
@ -275,77 +312,49 @@ async function saveHost(e) {
|
|||
const name = document.getElementById('formName').value.trim();
|
||||
const kuma = document.getElementById('formKumaUrl').value.trim();
|
||||
const enabled = document.getElementById('formEnabled').checked;
|
||||
|
||||
if (mode === 'add') {
|
||||
await apiFetch(`${API}/api/hosts`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ name, kuma_push_url: kuma })
|
||||
});
|
||||
toast(`${name} hinzugefügt`, 'success');
|
||||
await apiFetch(`${API}/api/hosts`, { method:'POST', headers:authHeaders(), body:JSON.stringify({name, kuma_push_url:kuma}) });
|
||||
toast(`${name} added`);
|
||||
} else {
|
||||
await apiFetch(`${API}/api/hosts/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ kuma_push_url: kuma, enabled })
|
||||
});
|
||||
toast(`${name} aktualisiert`, 'success');
|
||||
await apiFetch(`${API}/api/hosts/${name}`, { method:'PUT', headers:authHeaders(), body:JSON.stringify({kuma_push_url:kuma, enabled}) });
|
||||
toast(`${name} updated`);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
loadAll();
|
||||
closeModal(); loadAll();
|
||||
}
|
||||
|
||||
async function confirmDelete(name) {
|
||||
if (!confirm(`Host "${name}" und alle History wirklich löschen?`)) return;
|
||||
await apiFetch(`${API}/api/hosts/${name}`, { method: 'DELETE', headers: authHeaders() });
|
||||
toast(`${name} gelöscht`, 'success');
|
||||
closeDrawer();
|
||||
loadAll();
|
||||
if (!confirm(`Delete "${name}" and all history?`)) return;
|
||||
await apiFetch(`${API}/api/hosts/${name}`, { method:'DELETE', headers:authHeaders() });
|
||||
toast(`${name} deleted`); closeDrawer(); loadAll();
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
document.getElementById('modalOverlay').classList.add('open');
|
||||
document.getElementById('modal').classList.add('open');
|
||||
}
|
||||
function closeModal() {
|
||||
document.getElementById('modalOverlay').classList.remove('open');
|
||||
document.getElementById('modal').classList.remove('open');
|
||||
function openModal() { document.getElementById('modalBg').classList.remove('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.remove('scale-95','opacity-0','pointer-events-none'); }
|
||||
function closeModal() { document.getElementById('modalBg').classList.add('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.add('scale-95','opacity-0','pointer-events-none'); }
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────
|
||||
function showPage(page) {
|
||||
currentPage = page;
|
||||
['dashboard','alerts','hosts','config'].forEach(p => {
|
||||
document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page);
|
||||
// Nav highlights
|
||||
const nav = document.getElementById(`nav-${p}`);
|
||||
const side = document.getElementById(`side-${p}`);
|
||||
if (nav) { nav.className = p === page ? 'text-sm font-bold tracking-tight text-blue-400 px-3 py-1 rounded-lg font-headline' : 'text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline'; }
|
||||
if (side) { side.className = p === page ? 'flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-600/10 text-blue-400 border-r-2 border-blue-500 font-headline text-sm font-semibold' : 'flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-900/80 transition-all font-headline text-sm font-semibold'; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────
|
||||
function toast(msg, type = 'success') {
|
||||
const c = document.getElementById('toastContainer');
|
||||
// ── Toast ─────────────────────────────────────────────────
|
||||
function toast(msg) {
|
||||
const t = document.createElement('div');
|
||||
t.className = `toast ${type}`;
|
||||
t.innerHTML = `${type === 'success' ? '✓' : '✕'} ${msg}`;
|
||||
c.appendChild(t);
|
||||
t.className = 'glass px-5 py-3 rounded-xl text-sm font-medium text-white shadow-2xl flex items-center gap-2 animate-[slideIn_0.3s_ease-out]';
|
||||
t.innerHTML = `<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);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function fmtBytes(b) {
|
||||
if (!b || b === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(b) / Math.log(1024));
|
||||
return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function fmtDuration(sec) {
|
||||
if (!sec || sec === 0) return '–';
|
||||
if (sec < 60) return `${sec}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec/60)}m ${sec%60}s`;
|
||||
return `${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (diff < 60) return 'gerade eben';
|
||||
if (diff < 3600) return `vor ${Math.floor(diff/60)}m`;
|
||||
if (diff < 86400) return `vor ${Math.floor(diff/3600)}h`;
|
||||
return `vor ${Math.floor(diff/86400)}d`;
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return { ok: 'OK', stale: 'Überfällig', error: 'Fehler', disabled: 'Deaktiviert' }[s] || s;
|
||||
}
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
function fmtBytes(b) { if (!b) return '0 B'; const u = ['B','KB','MB','GB','TB']; const i = Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i>0?1:0)+' '+u[i]; }
|
||||
function fmtDuration(s) { if (!s) return '–'; if (s<60) return s+'s'; if (s<3600) return Math.floor(s/60)+'m'; return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; }
|
||||
function timeAgo(iso) { const d=(Date.now()-new Date(iso).getTime())/1000; if(d<60) return 'just now'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; }
|
||||
function statusChipClass(s) { return { ok:'bg-secondary/10 text-secondary border border-secondary/20', error:'bg-error/10 text-error border border-error/20', stale:'bg-tertiary/10 text-tertiary border border-tertiary/20', disabled:'bg-outline/10 text-outline border border-outline/20' }[s] || ''; }
|
||||
|
|
|
|||
|
|
@ -1,102 +1,295 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<html class="dark" lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backup Monitor – Pfannkuchen</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<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>">
|
||||
<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 ──────────────────────────────────────────── -->
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<span class="logo">🛡️</span>
|
||||
<h1>Backup Monitor</h1>
|
||||
<span class="subtitle">Homelab Pfannkuchen</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="pulse-dot" id="globalPulse"></div>
|
||||
<span id="lastUpdate" class="meta-text"></span>
|
||||
<button class="btn btn-icon" onclick="loadAll()" title="Aktualisieren">↻</button>
|
||||
<button class="btn btn-primary" onclick="openAddHost()">+ Host</button>
|
||||
</div>
|
||||
</header>
|
||||
<body class="bg-surface-dim font-body text-on-surface antialiased">
|
||||
|
||||
<!-- ── Summary Cards ───────────────────────────────────── -->
|
||||
<section class="summary-grid" id="summaryGrid">
|
||||
<div class="card summary-card" id="cardTotal">
|
||||
<div class="card-value" id="valTotal">–</div>
|
||||
<div class="card-label">Hosts</div>
|
||||
</div>
|
||||
<div class="card summary-card card-ok" id="cardOk">
|
||||
<div class="card-value" id="valOk">–</div>
|
||||
<div class="card-label">OK</div>
|
||||
</div>
|
||||
<div class="card summary-card card-warn" id="cardStale">
|
||||
<div class="card-value" id="valStale">–</div>
|
||||
<div class="card-label">Überfällig</div>
|
||||
</div>
|
||||
<div class="card summary-card card-error" id="cardError">
|
||||
<div class="card-value" id="valError">–</div>
|
||||
<div class="card-label">Fehler</div>
|
||||
</div>
|
||||
<div class="card summary-card" id="cardToday">
|
||||
<div class="card-value" id="valToday">–</div>
|
||||
<div class="card-label">Heute</div>
|
||||
</div>
|
||||
<div class="card summary-card" id="cardSize">
|
||||
<div class="card-value" id="valSize">–</div>
|
||||
<div class="card-label">Heute gesichert</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Host Grid ───────────────────────────────────────── -->
|
||||
<section class="host-grid" id="hostGrid"></section>
|
||||
|
||||
<!-- ── Detail Drawer ───────────────────────────────────── -->
|
||||
<div class="drawer-overlay" id="drawerOverlay" onclick="closeDrawer()"></div>
|
||||
<aside class="drawer" id="drawer">
|
||||
<div class="drawer-header">
|
||||
<h2 id="drawerTitle">Host Details</h2>
|
||||
<button class="btn btn-icon" onclick="closeDrawer()">✕</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="drawerBody"></div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Add/Edit Host Modal ─────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()"></div>
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Host hinzufügen</h2>
|
||||
<button class="btn btn-icon" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<form id="hostForm" onsubmit="saveHost(event)">
|
||||
<input type="hidden" id="formMode" value="add">
|
||||
<div class="form-group">
|
||||
<label>Hostname</label>
|
||||
<input type="text" id="formName" placeholder="z.B. arrapps" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Uptime Kuma Push-URL</label>
|
||||
<input type="url" id="formKumaUrl" placeholder="https://status.guck.tv/api/push/borg-hostname?status=up&msg=OK">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="formEnabled" checked>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" onclick="closeModal()">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- ── 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>
|
||||
|
||||
<!-- ── Toast ───────────────────────────────────────────── -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
<!-- ── 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>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<!-- ── 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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue