diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3af2921..5d0b542 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from './api/client'; +import { AttentionHero } from './components/AttentionHero'; import { AuditLog } from './components/AuditLog'; import { BackupsSection } from './components/BackupsSection'; import { CloudflareSection } from './components/CloudflareSection'; @@ -8,8 +9,9 @@ import { ConfirmModal } from './components/ConfirmModal'; import { Drawer } from './components/Drawer'; import { GuestDrawer } from './components/GuestDrawer'; import { Header } from './components/Header'; +import { HostPanel } from './components/HostPanel'; +import { KpiStrip } from './components/KpiStrip'; import { NetworkPanel } from './components/NetworkPanel'; -import { OverviewCards } from './components/OverviewCards'; import { SectionNav } from './components/SectionNav'; import { ServiceGrid } from './components/ServiceGrid'; import { SettingsPage } from './components/SettingsPage'; @@ -42,8 +44,6 @@ export function App() { setTimeout(() => setToasts((s) => s.filter((x) => x.id !== id)), 4500); }, []); - // Cmd/Ctrl+K toggles the palette; "?" opens the keyboard cheatsheet; - // 1-6 jumps to sections (when not in an input). useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; @@ -68,8 +68,6 @@ export function App() { return () => window.removeEventListener('keydown', onKey); }, [setSection]); - // Pause swaps polling intervals to 0 (no auto-refresh) while keeping the - // last known state in memory. Manual refresh still works. const pollFast = paused ? 0 : ui.refreshIntervalMs; const pollBackup = paused ? 0 : ui.pollBackupMs; const pollCerts = paused ? 0 : ui.pollCertsMs; @@ -81,14 +79,6 @@ export function App() { const net = usePoll((sig) => api.network(sig), pollFast); const cer = usePoll((sig) => api.certs(sig), pollCerts); - const lastRefresh = Math.max( - sys.lastFetched, - svc.lastFetched, - tun.lastFetched, - bkp.lastFetched, - net.lastFetched, - ); - const refreshAll = useCallback(() => { sys.refresh(); svc.refresh(); @@ -101,8 +91,8 @@ export function App() { const services = useMemo(() => svc.data ?? [], [svc.data]); const guests = useMemo(() => sys.data?.guests ?? [], [sys.data]); const servicesUp = services.filter((s) => s.status === 'ok').length; + const servicesCritical = services.filter((s) => s.status === 'err').length; - // Per-section alert counts shown as pills on the nav tabs. const alerts = useMemo>(() => { const svcBad = services.filter((s) => s.status === 'err' || s.status === 'warn').length; const hostBad = sys.data?.host && !sys.data.host.online ? 1 : 0; @@ -133,7 +123,6 @@ export function App() { ); const onLogs = (g: Guest) => setLogsGuest(g); - const onRestart = (g: Guest) => setConfirmGuest(g); const confirmRestart = async () => { @@ -142,48 +131,30 @@ export function App() { setConfirmGuest(null); try { await api.restartGuest(guest.id, guest.type === 'VM' ? 'qemu' : 'lxc'); - pushToast({ - level: 'warn', - title: `${guest.name} restarting`, - body: `Container ${guest.id} wird neu gestartet`, - }); + pushToast({ level: 'warn', title: `${guest.name} restarting`, body: `Container ${guest.id} wird neu gestartet` }); setTimeout(refreshAll, 3000); } catch (e) { - pushToast({ - level: 'err', - title: 'Restart fehlgeschlagen', - body: (e as Error).message, - }); + pushToast({ level: 'err', title: 'Restart fehlgeschlagen', body: (e as Error).message }); } }; const onVerifyBackup = useCallback( async (snap: BackupSnapshot) => { const ok = window.confirm( - `Verifikation für ${snap.target} (${new Date(snap.backup_time * 1000).toLocaleString()}) starten?\n\nDer Job läuft asynchron auf dem PBS und kann bei großen Backups länger dauern.`, + `Verifikation für ${snap.target} (${new Date(snap.backup_time * 1000).toLocaleString()}) starten?`, ); if (!ok) return; try { const r = await api.verifyBackup(snap.backup_type, snap.backup_id, snap.backup_time); - pushToast({ - level: 'ok', - title: `Verify gestartet · ${snap.target}`, - body: `UPID: ${r.upid.slice(0, 32)}…`, - }); + pushToast({ level: 'ok', title: `Verify gestartet · ${snap.target}`, body: `UPID: ${r.upid.slice(0, 32)}…` }); setTimeout(bkp.refresh, 2000); } catch (e) { - pushToast({ - level: 'err', - title: 'Verify fehlgeschlagen', - body: (e as Error).message, - }); + pushToast({ level: 'err', title: 'Verify fehlgeschlagen', body: (e as Error).message }); } }, [pushToast, bkp], ); - // Build a JSON snapshot of the current dashboard state and copy it to the - // clipboard. Useful for filing issues without having to screenshot. const onSnapshot = useCallback(async () => { const snapshot = { capturedAt: new Date().toISOString(), @@ -198,19 +169,13 @@ export function App() { const text = JSON.stringify(snapshot, null, 2); try { await navigator.clipboard.writeText(text); - pushToast({ level: 'ok', title: 'Snapshot kopiert', body: `${text.length.toLocaleString()} Zeichen in der Zwischenablage` }); + pushToast({ level: 'ok', title: 'Snapshot kopiert', body: `${text.length.toLocaleString()} Zeichen` }); } catch (e) { - pushToast({ - level: 'err', - title: 'Snapshot fehlgeschlagen', - body: (e as Error).message || 'Zwischenablage nicht verfügbar', - }); + pushToast({ level: 'err', title: 'Snapshot fehlgeschlagen', body: (e as Error).message || 'Zwischenablage nicht verfügbar' }); } }, [me.data, sys.data, svc.data, tun.data, bkp.data, net.data, cer.data, pushToast]); const anyError = sys.error || svc.error || tun.error; - - // Surface persistent backend failures as a toast (once per error transition). const errSig = `${sys.error?.message ?? ''}|${svc.error?.message ?? ''}|${tun.error?.message ?? ''}|${bkp.error?.message ?? ''}|${net.error?.message ?? ''}`; const lastErrSig = useRef(''); useEffect(() => { @@ -224,13 +189,20 @@ export function App() { { name: 'Netzwerk', err: net.error }, ]; for (const { name, err } of errs) { - if (err) { - pushToast({ level: 'err', title: `${name}-API Fehler`, body: err.message }); - } + if (err) pushToast({ level: 'err', title: `${name}-API Fehler`, body: err.message }); } }, [errSig, sys.error, svc.error, tun.error, bkp.error, net.error, pushToast]); - const overallLoading = sys.loading || tun.loading || bkp.loading; + const onInspectGuest = (vmid: number) => { + const g = guests.find((x) => x.id === vmid); + if (g) setLogsGuest(g); + }; + + const onToggleTheme = useCallback(() => { + const order: ('dark' | 'light' | 'auto')[] = ['dark', 'light', 'auto']; + const next = order[(order.indexOf(ui.theme) + 1) % order.length]; + setUI('theme', next); + }, [ui.theme, setUI]); return (
setPaused((p) => !p)} onSnapshot={onSnapshot} - refreshIntervalMs={ui.refreshIntervalMs} - onChangeRefreshInterval={(ms) => setUI('refreshIntervalMs', ms)} + onOpenSettings={() => setSection('settings')} + onToggleTheme={onToggleTheme} + isDarkTheme={resolvedTheme === 'dark'} /> {anyError && ( @@ -264,40 +237,41 @@ export function App() { {paused && (
Auto-Refresh pausiert — Daten werden nicht aktualisiert. - +
)}
{section === 'overview' && ( <> - + +
+ + +
- )} {section === 'server' && ( <> - + +
+ + +
)} - {section === 'network' && ( - - )} + {section === 'network' && } {section === 'backup' && ( setConfirmGuest(g)} onRefresh={refreshAll} - onToggleTheme={() => { - const order: ('dark' | 'light' | 'auto')[] = ['dark', 'light', 'auto']; - const next = order[(order.indexOf(ui.theme) + 1) % order.length]; - setUI('theme', next); - }} + onToggleTheme={onToggleTheme} onJumpSection={setSection} /> setHelpOpen(false)} /> diff --git a/frontend/src/components/AttentionHero.tsx b/frontend/src/components/AttentionHero.tsx new file mode 100644 index 0000000..1ef3979 --- /dev/null +++ b/frontend/src/components/AttentionHero.tsx @@ -0,0 +1,197 @@ +import type { Guest, ServiceStatus, CertInfo, BackupSummary } from '../types'; +import { ICONS, fmtTimeAgo } from './primitives'; + +export interface Alert { + id: string; + level: 'crit' | 'warn' | 'info'; + target: string; + sub: string; + msg: string; + iso: string | null; + actions: ('inspect' | 'restart' | 'renew')[]; + /** Optional click handler — opens the matching drawer. */ + onClick?: () => void; +} + +interface Props { + guests: Guest[]; + services: ServiceStatus[]; + certs: CertInfo[]; + backups: BackupSummary | null; + certWarnDays: number; + onInspectService?: (id: string) => void; + onInspectGuest?: (id: number) => void; +} + +const HEALTH_WEIGHTS = { svc_err: 15, svc_warn: 6, ram_crit: 12, ram_warn: 5, cert_crit: 10, cert_warn: 4, backup_fail: 8 } as const; + +function deriveAlerts(p: Props): Alert[] { + const out: Alert[] = []; + for (const g of p.guests) { + const ramPct = g.ram_total_b > 0 ? (g.ram_used_b / g.ram_total_b) * 100 : 0; + if (ramPct > 95) { + out.push({ + id: `ram-${g.id}`, + level: 'crit', + target: g.name, + sub: g.ip ?? `CT/VM ${g.id}`, + msg: `RAM-Auslastung bei ${Math.round(ramPct)}%`, + iso: null, + actions: ['inspect', 'restart'], + onClick: () => p.onInspectGuest?.(g.id), + }); + } else if (g.cpu_pct > 90) { + out.push({ + id: `cpu-${g.id}`, + level: 'warn', + target: g.name, + sub: g.ip ?? `CT/VM ${g.id}`, + msg: `CPU dauerhaft bei ${Math.round(g.cpu_pct)}%`, + iso: null, + actions: ['inspect'], + onClick: () => p.onInspectGuest?.(g.id), + }); + } + } + for (const s of p.services) { + if (s.status === 'err') { + out.push({ + id: `svc-${s.id}`, + level: 'crit', + target: s.name, + sub: s.sub, + msg: s.note ?? `${s.code_ext ? `HTTP ${s.code_ext}` : 'Probe fehlgeschlagen'} — Origin nicht erreichbar`, + iso: s.last_incident_iso, + actions: ['inspect'], + onClick: () => p.onInspectService?.(s.id), + }); + } else if (s.status === 'warn') { + out.push({ + id: `svc-${s.id}`, + level: 'warn', + target: s.name, + sub: s.sub, + msg: s.note ?? `Externe Probe fehlgeschlagen — ${s.ms}ms`, + iso: s.last_incident_iso, + actions: ['inspect'], + onClick: () => p.onInspectService?.(s.id), + }); + } + } + for (const c of p.certs) { + if (c.days_left <= 0) { + out.push({ id: `cert-${c.domain}`, level: 'crit', target: c.domain, sub: c.issuer, msg: 'Cert abgelaufen', iso: null, actions: ['renew'] }); + } else if (c.days_left < p.certWarnDays) { + out.push({ + id: `cert-${c.domain}`, + level: c.days_left < 7 ? 'crit' : 'warn', + target: c.domain, + sub: c.issuer, + msg: `Cert läuft in ${c.days_left} Tagen ab`, + iso: null, + actions: ['renew'], + }); + } + } + if (p.backups && !p.backups.reachable) { + out.push({ id: 'backup-unreach', level: 'crit', target: 'PBS', sub: 'backup', msg: p.backups.error ?? 'Datastore nicht erreichbar', iso: null, actions: ['inspect'] }); + } + // Order: crit before warn before info, then alphabetical. + out.sort((a, b) => { + const order = { crit: 0, warn: 1, info: 2 } as const; + if (order[a.level] !== order[b.level]) return order[a.level] - order[b.level]; + return a.target.localeCompare(b.target); + }); + return out; +} + +function computeHealth(p: Props, alerts: Alert[]): number { + let penalty = 0; + for (const a of alerts) { + if (a.id.startsWith('svc-')) penalty += a.level === 'crit' ? HEALTH_WEIGHTS.svc_err : HEALTH_WEIGHTS.svc_warn; + else if (a.id.startsWith('ram-')) penalty += HEALTH_WEIGHTS.ram_crit; + else if (a.id.startsWith('cpu-')) penalty += HEALTH_WEIGHTS.ram_warn; + else if (a.id.startsWith('cert-')) penalty += a.level === 'crit' ? HEALTH_WEIGHTS.cert_crit : HEALTH_WEIGHTS.cert_warn; + else if (a.id.startsWith('backup-')) penalty += HEALTH_WEIGHTS.backup_fail; + } + void p; + return Math.max(0, 100 - penalty); +} + +export function AttentionHero(p: Props) { + const alerts = deriveAlerts(p); + const crit = alerts.filter((a) => a.level === 'crit'); + const warn = alerts.filter((a) => a.level === 'warn'); + const visible = [...crit, ...warn].slice(0, 4); + const health = computeHealth(p, alerts); + const scoreClass = health < 70 ? 'err' : health < 90 ? 'warn' : ''; + const desc = crit.length > 0 + ? `${crit.length} Service${crit.length === 1 ? '' : 's'} benötigen sofortige Aufmerksamkeit.${visible[0] ? ` Beginne mit ${visible[0].target}.` : ''}` + : warn.length > 0 + ? `${warn.length} Auffälligkeit${warn.length === 1 ? '' : 'en'} — nichts kritisches.` + : 'Alle Systeme nominal.'; + + return ( +
+
+ System Health +
+ {health} + / 100 +
+ {health < 100 && ( + + {ICONS.warn} + −{100 - health} · {crit.length} kritisch + + )} +

{desc}

+ + +
+
+ {ICONS.bell} Active Alerts · {alerts.length} + {alerts.length > visible.length && ( + + {alerts.length - visible.length} weitere + + )} +
+
+ {visible.length === 0 ? ( +
Keine offenen Alerts — wir machen weiter, ihr macht weiter.
+ ) : ( + visible.map((a) => ( + + )) + )} +
+
+
+ ); +} diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 9626487..2a5d0f4 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -1,14 +1,31 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import { fmtTimeAgo } from './primitives'; +import { ICONS, fmtTimeAgo } from './primitives'; type Event = Record & { ts: number; event: string }; +function level(event: string): 'ok' | 'warn' | 'err' | 'info' { + if (event.endsWith('.failure') || event.includes('.error')) return 'err'; + if (event.endsWith('.warn') || event.includes('.degraded')) return 'warn'; + if (event.endsWith('.result') || event.endsWith('.success') || event.endsWith('.recovered')) return 'ok'; + return 'info'; +} + +function describe(e: Event): { strong?: string; tag?: string; text: string } { + const target = (e.target as string | undefined) ?? (e.guest as string | undefined) ?? (e.service as string | undefined); + const action = e.event.replace(/^(guest|service|backup|cert|cloudflare|system)\./, ''); + const status = e.status as string | undefined; + const tag = e.event; + if (target) { + return { strong: target, tag, text: status ? `${action} → ${status}` : action }; + } + return { tag, text: action }; +} + export function AuditLog() { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); - const lastFetchRef = useRef(0); useEffect(() => { let cancelled = false; @@ -18,7 +35,6 @@ export function AuditLog() { if (cancelled) return; setEvents(r.events as Event[]); setError(false); - lastFetchRef.current = Date.now(); } catch { if (!cancelled) setError(true); } finally { @@ -34,52 +50,38 @@ export function AuditLog() { }, []); return ( -
-
-

- Audit-Log · letzte {events.length} -

+
+
+

Activity letzte 24h

{error ? 'Fehler beim Laden' : 'auto · 30s'}
-
+
{loading && events.length === 0 ? ( -
- Lade… -
+
Lade…
) : events.length === 0 ? ( -
- Noch keine Audit-Events seit Backend-Start. -
+
Noch keine Events.
) : ( -
    - {events.map((e, i) => ( -
  • - {e.event} - {formatFields(e)} - - {fmtTimeAgo(new Date((e.ts as number) * 1000).toISOString())} + events.slice(0, 12).map((e, i) => { + const lvl = level(e.event); + const d = describe(e); + return ( +
    + + + {d.strong && {d.strong} } + {d.tag && {d.tag}} + {d.text} -
  • - ))} -
+ {fmtTimeAgo(new Date((e.ts as number) * 1000).toISOString())} +
+ ); + }) )}
-
+
); } - -function classify(event: string): string { - if (event.endsWith('.result')) return 'result'; - if (event.startsWith('guest.')) return 'guest'; - return 'other'; -} - -function formatFields(e: Event): string { - const omit = new Set(['ts', 'event']); - return Object.entries(e) - .filter(([k]) => !omit.has(k)) - .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) - .join(' '); -} diff --git a/frontend/src/components/BackupsSection.tsx b/frontend/src/components/BackupsSection.tsx index f2f126d..b114264 100644 --- a/frontend/src/components/BackupsSection.tsx +++ b/frontend/src/components/BackupsSection.tsx @@ -54,7 +54,7 @@ export function BackupsSection({ backups, guests, onVerify, onOpenGuest }: Props usedPct > 85 ? 'var(--err)' : usedPct > 70 ? 'var(--warn)' : 'var(--accent)'; return ( -
+
diff --git a/frontend/src/components/CloudflareSection.tsx b/frontend/src/components/CloudflareSection.tsx index b178746..31ecad8 100644 --- a/frontend/src/components/CloudflareSection.tsx +++ b/frontend/src/components/CloudflareSection.tsx @@ -51,10 +51,10 @@ export function CloudflareSection({ }, [certs, services]); return ( -
-

- Cloudflare -

+
+
+

Cloudflare

+
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 68fe1db..9d75c32 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,11 +1,9 @@ -import { REFRESH_INTERVALS_MS } from '../hooks/useTheme'; -import { ICONS, fmtClock } from './primitives'; -import { StatusHistory } from './StatusHistory'; +import { ICONS } from './primitives'; interface HeaderProps { servicesUp: number; servicesTotal: number; - lastRefresh: number; + servicesCritical: number; onRefresh: () => void; refreshing: boolean; email: string | null; @@ -13,19 +11,22 @@ interface HeaderProps { paused: boolean; onTogglePause: () => void; onSnapshot: () => void; - refreshIntervalMs: number; - onChangeRefreshInterval: (ms: number) => void; -} - -function refreshLabel(ms: number): string { - if (ms === 0) return 'aus'; - if (ms < 60_000) return `${ms / 1000}s`; - return `${ms / 60_000}min`; + onOpenSettings: () => void; + onToggleTheme: () => void; + isDarkTheme: boolean; } export function Header(p: HeaderProps) { - const allUp = p.servicesUp === p.servicesTotal && p.servicesTotal > 0; - const initials = (p.email ?? '?').slice(0, 2).toUpperCase(); + const degraded = p.servicesTotal - p.servicesUp; + const status: 'ok' | 'warn' | 'err' = p.servicesCritical > 0 ? 'err' : degraded > 0 ? 'warn' : 'ok'; + const label = p.servicesTotal === 0 + ? 'Initialisierung…' + : p.servicesCritical > 0 + ? `${p.servicesCritical} kritisches Issue${p.servicesCritical === 1 ? '' : 's'} · ${degraded} degraded` + : degraded > 0 + ? `${degraded} von ${p.servicesTotal} Services degraded` + : 'Alle Systeme operational'; + const initials = (p.email ?? 'RF').replace(/[^A-Za-z]/g, '').slice(0, 2).toUpperCase() || 'RF'; return (
@@ -33,19 +34,13 @@ export function Header(p: HeaderProps) {
@@ -53,106 +48,82 @@ export function Header(p: HeaderProps) { admin
- +
-
-
-
- - {p.refreshing && ( - - )} - Last refresh - - - {fmtClock(p.lastRefresh)} - -
- - + {ICONS.refresh} + +
-
-
{initials}
-
- {p.email ?? 'unknown'} - - via Cloudflare Access - -
-
+
{initials}
); diff --git a/frontend/src/components/HostPanel.tsx b/frontend/src/components/HostPanel.tsx new file mode 100644 index 0000000..0f7f228 --- /dev/null +++ b/frontend/src/components/HostPanel.tsx @@ -0,0 +1,113 @@ +import type { Guest, HostStatus } from '../types'; +import { Dot, ICONS, StackedBar, TrendBars, fmtBytes, fmtUptime, type StackedSegment } from './primitives'; + +interface Props { + host: HostStatus | null; + guests: Guest[]; + /** CPU trend samples (most recent last). Falls back to a flat line. */ + cpuTrend?: number[]; + /** Disk growth trend. */ + diskTrend?: number[]; +} + +// Stable peach→indigo→info palette for stacked-bar segments. +const SEG_COLORS = [ + '#ffb17a', '#5a608a', '#4f9eff', '#00d97e', '#ffd6b1', + '#424769', '#7d83b3', '#3acf86', '#ff7a59', '#a8aed5', +]; + +function ramByGuest(guests: Guest[]): StackedSegment[] { + const consumers = guests + .filter((g) => g.ram_used_b > 0) + .map((g) => ({ name: g.name, gb: g.ram_used_b / 1024 ** 3 })) + .sort((a, b) => b.gb - a.gb); + return consumers.map((c, i) => ({ ...c, color: SEG_COLORS[i % SEG_COLORS.length] })); +} + +export function HostPanel({ host, guests, cpuTrend, diskTrend }: Props) { + if (!host) { + return ( +
+
+
+

Proxmox Host

+ WAITING +
+
+
Initialisierung…
+
+ ); + } + const ramPct = host.ram_total_b > 0 ? (host.ram_used_b / host.ram_total_b) * 100 : 0; + const diskPct = host.disk_total_b > 0 ? (host.disk_used_b / host.disk_total_b) * 100 : 0; + const status = host.online ? 'ok' : 'err'; + const segments = ramByGuest(guests); + const cpuData = cpuTrend && cpuTrend.length > 0 ? cpuTrend : Array.from({ length: 48 }, () => host.cpu_pct); + const diskData = diskTrend && diskTrend.length > 0 ? diskTrend : Array.from({ length: 48 }, (_, i) => diskPct - (47 - i) * 0.01); + const cpuMax = Math.max(...cpuData); + const cpuAvg = cpuData.reduce((a, b) => a + b, 0) / cpuData.length; + const disksLabel = host.disks.length === 0 + ? 'keine Disks' + : `${host.disks.length} disks · ${host.disks.every((d) => d.health === 'PASSED') ? 'PASSED' : 'CHECK'}`; + return ( +
+
+
+

Proxmox Host

+ + {host.online ? 'ONLINE' : 'OFFLINE'} + + {host.cpu_temp_c != null && ( + {ICONS.temp}{host.cpu_temp_c}°C + )} + {ICONS.disk}{disksLabel} +
+ + {host.node} + {host.pve_version && ` · PVE ${host.pve_version}`} + {host.kernel && ` · ${host.kernel}`} + {host.uptime_s > 0 && ` · up ${fmtUptime(host.uptime_s)}`} + +
+ +
+
CPUlast 48h
+
+ {host.cpu_pct.toFixed(1)}% + · {host.cpu_cores} cores +
+ avg {Math.round(cpuAvg)}% · peak {Math.round(cpuMax)}% + +
+ +
+
RAMby guest
+
+ {(host.ram_used_b / 1024 ** 3).toFixed(1)}/ {(host.ram_total_b / 1024 ** 3).toFixed(1)} GB + · {Math.round(ramPct)}% +
+ {segments.length} consumers · {fmtBytes(host.ram_used_b)} used + +
+ {segments.slice(0, 5).map((s) => ( + + + {s.name} + {s.gb.toFixed(1)} GB + + ))} +
+
+ +
+
Disk · rpoolusage
+
+ {(host.disk_used_b / 1024 ** 3).toFixed(1)}/ {(host.disk_total_b / 1024 ** 3).toFixed(1)} GB + · {Math.round(diskPct)}% +
+ {fmtBytes(host.disk_total_b - host.disk_used_b)} frei + +
+
+ ); +} diff --git a/frontend/src/components/KpiStrip.tsx b/frontend/src/components/KpiStrip.tsx new file mode 100644 index 0000000..7fa9979 --- /dev/null +++ b/frontend/src/components/KpiStrip.tsx @@ -0,0 +1,77 @@ +import type { Guest, ServiceStatus } from '../types'; +import { Sparkline } from './primitives'; + +interface Props { + guests: Guest[]; + services: ServiceStatus[]; + /** Optional sparkline series for the worst-offender service's response-time. */ + worstSpark?: number[]; +} + +export function KpiStrip({ guests, services, worstSpark }: Props) { + const running = guests.filter((g) => g.running).length; + const total = guests.length; + const svcOk = services.filter((s) => s.status === 'ok').length; + const svcTotal = services.length; + const avgMs = svcTotal > 0 + ? Math.round(services.reduce((a, s) => a + s.ms, 0) / svcTotal) + : 0; + const uptimeValues = services.map((s) => s.uptime_pct).filter((v): v is number => v != null); + const sla = uptimeValues.length > 0 + ? (uptimeValues.reduce((a, b) => a + b, 0) / uptimeValues.length).toFixed(2) + : '—'; + const containersHealthy = total > 0 && running === total; + const servicesHealthy = svcTotal > 0 && svcOk === svcTotal; + const worst = services + .filter((s) => s.status !== 'ok') + .sort((a, b) => b.ms - a.ms)[0]; + const spark = worstSpark && worstSpark.length > 0 + ? worstSpark + : worst + ? [worst.ms * 0.7, worst.ms * 0.8, worst.ms * 0.95, worst.ms] + : null; + + return ( +
+
+ Container Up + + {running}/ {total} + + + {containersHealthy + ? <>▲ 100% running + : <>{total - running} down} + +
+
+ Services Healthy + + {svcOk}/ {svcTotal} + + + {servicesHealthy ? 'alle OK' : `${svcTotal - svcOk} betroffen`} + +
+
+ Avg Response + 1000 ? 'err' : avgMs > 300 ? 'warn' : ''}`}> + {avgMs}ms + + + {worst ? <>spike: {worst.name} : 'stabil'} + + {spark && ( + + )} +
+
+ SLA · 30d + {sla}% + Target: 99.9% +
+
+ ); +} diff --git a/frontend/src/components/NetworkPanel.tsx b/frontend/src/components/NetworkPanel.tsx index c970b75..8a74e58 100644 --- a/frontend/src/components/NetworkPanel.tsx +++ b/frontend/src/components/NetworkPanel.tsx @@ -44,8 +44,8 @@ export function NetworkPanel({ network, tunnel }: Props) { const devices = network?.devices ?? []; return ( -
-
+
+

Netzwerk

@@ -117,10 +117,8 @@ export function NetworkPanel({ network, tunnel }: Props) { {devices.length > 0 && (
-
-

- UniFi Devices · {devices.length} online -

+
+

UniFi Devices · {devices.length} online

{devices.map((d) => ( diff --git a/frontend/src/components/OverviewCards.tsx b/frontend/src/components/OverviewCards.tsx deleted file mode 100644 index a5c14d6..0000000 --- a/frontend/src/components/OverviewCards.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import type { BackupSummary, Guest, HostStatus, TunnelStatus } from '../types'; -import { Donut, Dot, Num, fmtBytes, fmtTimeAgo, fmtUptime } from './primitives'; - -export type OverviewCard = 'host' | 'guests' | 'tunnel' | 'backup'; - -interface Props { - host: HostStatus | null; - guests: Guest[]; - tunnel: TunnelStatus | null; - backups: BackupSummary | null; - loading?: boolean; - /** - * Restrict the rendered set of cards. ``undefined`` renders all four (the - * default overview tab); a list lets a section tab show only the cards - * relevant to it (e.g. just the host card on the Server tab). - */ - only?: OverviewCard[]; -} - -export function OverviewCards({ host, guests, tunnel, backups, loading, only }: Props) { - const initialLoad = loading && !host && !tunnel && !backups && guests.length === 0; - if (initialLoad) { - const count = only?.length ?? 4; - return ( -
- {Array.from({ length: count }).map((_, i) => ( -