1028 lines
62 KiB
JavaScript
1028 lines
62 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
|
||
const API = process.env.REACT_APP_API_URL || '/api';
|
||
|
||
const apiFetch = (url) => fetch(url).then(r => r.ok ? r.json() : null).catch(() => null);
|
||
|
||
/* ── Design tokens ─────────────────────────────────────────── */
|
||
const ACCENT = '#4FD1C5';
|
||
const C_OK = '#34D399';
|
||
const C_WARN = '#F59E0B';
|
||
const C_ERROR = '#EF4444';
|
||
const C_INFO = '#60A5FA';
|
||
// const C_CRIT = '#F43F5E'; // reserved for future use
|
||
|
||
/* ── Mesh palettes per slide tint ──────────────────────────── */
|
||
const MESH_PAL = {
|
||
mint: ['#0a3d3a','#0d4f48','#082b29','#0b1f1d'],
|
||
red: ['#3d0a1a','#4f0d22','#2b0810','#1f0b0e'],
|
||
blue: ['#0a1f3d','#0d2a4f','#08152b','#0b121f'],
|
||
violet: ['#22093d','#2d0c4f','#16062b','#10071f'],
|
||
neutral: ['#0a1014','#0e1419','#070b0e','#04070a'],
|
||
};
|
||
|
||
/* ════════════════════════════════════════════════════════════
|
||
HOOKS
|
||
════════════════════════════════════════════════════════════ */
|
||
function useNow(interval = 1000) {
|
||
const [now, setNow] = useState(() => new Date());
|
||
useEffect(() => {
|
||
const id = setInterval(() => setNow(new Date()), interval);
|
||
return () => clearInterval(id);
|
||
}, [interval]);
|
||
return now;
|
||
}
|
||
|
||
function useCountUp(target, durationMs = 1400, deps = []) {
|
||
const [val, setVal] = useState(target);
|
||
useEffect(() => {
|
||
if (document.visibilityState !== 'visible') { setVal(target); return; }
|
||
let raf;
|
||
const start = performance.now();
|
||
const to = Number(target) || 0;
|
||
setVal(0);
|
||
const tick = (t) => {
|
||
const p = Math.min(1, (t - start) / durationMs);
|
||
const eased = 1 - Math.pow(1 - p, 3);
|
||
setVal(to * eased);
|
||
if (p < 1) raf = requestAnimationFrame(tick);
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
const safety = setTimeout(() => setVal(to), 200);
|
||
return () => { cancelAnimationFrame(raf); clearTimeout(safety); };
|
||
// eslint-disable-next-line
|
||
}, deps);
|
||
return val;
|
||
}
|
||
|
||
function useStageScale(w = 1920, h = 1080) {
|
||
const [scale, setScale] = useState(1);
|
||
useEffect(() => {
|
||
const fit = () => {
|
||
const vw = window.innerWidth, vh = window.innerHeight;
|
||
if (vw < 4 || vh < 4) return;
|
||
setScale(Math.min(vw / w, vh / h));
|
||
};
|
||
fit();
|
||
window.addEventListener('resize', fit);
|
||
const retries = [50, 150, 400].map(t => setTimeout(fit, t));
|
||
return () => { window.removeEventListener('resize', fit); retries.forEach(clearTimeout); };
|
||
}, [w, h]);
|
||
return scale;
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════
|
||
ATOMS
|
||
════════════════════════════════════════════════════════════ */
|
||
function MeshBackground({ tint = 'neutral', intensity = 1 }) {
|
||
const p = MESH_PAL[tint] || MESH_PAL.neutral;
|
||
const k = intensity;
|
||
return (
|
||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
||
<div style={{ position: 'absolute', inset: 0, background: '#000' }} />
|
||
<div className="tv-blob tv-b1" style={{ background: p[0], opacity: 0.85 * k }} />
|
||
<div className="tv-blob tv-b2" style={{ background: p[1], opacity: 0.7 * k }} />
|
||
<div className="tv-blob tv-b3" style={{ background: p[2], opacity: 0.6 * k }} />
|
||
<div className="tv-blob tv-b4" style={{ background: p[3], opacity: 0.5 * k }} />
|
||
<div style={{ position: 'absolute', inset: 0, opacity: 0.06, mixBlendMode: 'overlay',
|
||
backgroundImage: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>")` }} />
|
||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.55) 100%)' }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ClockBig() {
|
||
const now = useNow(1000);
|
||
const hh = String(now.getHours()).padStart(2, '0');
|
||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||
const dateStr = now.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||
return (
|
||
<div style={{ position: 'absolute', top: 56, right: 80, textAlign: 'right', fontFeatureSettings: '"tnum" 1', zIndex: 5 }}>
|
||
<div style={{ fontSize: 76, fontWeight: 600, letterSpacing: -2.5, lineHeight: 1, display: 'inline-flex', alignItems: 'baseline' }}>
|
||
<span>{hh}</span>
|
||
<span style={{ opacity: 0.32, margin: '0 2px', fontWeight: 300 }}>:</span>
|
||
<span>{mm}</span>
|
||
<span style={{ opacity: 0.32, margin: '0 2px', fontWeight: 300, fontSize: 56 }}>:</span>
|
||
<span style={{ fontSize: 44, opacity: 0.4, fontWeight: 400, letterSpacing: -1 }}>{ss}</span>
|
||
</div>
|
||
<div style={{ fontSize: 16, color: 'rgba(244,245,247,0.62)', marginTop: 10, letterSpacing: 0.4, fontWeight: 500, textTransform: 'capitalize' }}>{dateStr}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BrandChip({ section }) {
|
||
return (
|
||
<div style={{ position: 'absolute', top: 64, left: 80, display: 'inline-flex', alignItems: 'center', gap: 16, fontSize: 14, fontWeight: 700, letterSpacing: 2.2, zIndex: 5 }}>
|
||
<span className="tv-brand-dot" style={{ background: ACCENT }} />
|
||
<span style={{ color: '#F4F5F7' }}>IT NEXUS</span>
|
||
{section && <>
|
||
<span style={{ width: 1, height: 16, background: 'rgba(255,255,255,0.14)' }} />
|
||
<span style={{ color: 'rgba(244,245,247,0.62)', letterSpacing: 1.6, fontWeight: 500, textTransform: 'uppercase' }}>{section}</span>
|
||
</>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SlideIndicator({ total, active, durationMs, autoplay }) {
|
||
return (
|
||
<div style={{ position: 'absolute', top: 80, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8, zIndex: 5 }}>
|
||
{Array.from({ length: total }).map((_, i) => (
|
||
<div key={i} style={{
|
||
width: i === active ? 64 : 28, height: 3, borderRadius: 2,
|
||
background: i < active ? 'rgba(255,255,255,0.28)' : i === active ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.12)',
|
||
overflow: 'hidden', transition: 'width 0.5s cubic-bezier(.2,.7,.2,1)',
|
||
}}>
|
||
{i === active && autoplay && (
|
||
<div style={{ width: 0, height: '100%', background: ACCENT, boxShadow: `0 0 6px ${ACCENT}`, animation: `tvSiFill ${durationMs}ms linear forwards` }} />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Sparkline({ data = [], width = 400, height = 80, color = ACCENT, fill = true, strokeW = 2 }) {
|
||
if (!data || data.length < 2) return null;
|
||
const min = Math.min(...data), max = Math.max(...data);
|
||
const range = max - min || 1;
|
||
const step = width / (data.length - 1);
|
||
const pts = data.map((v, i) => [i * step, height - ((v - min) / range) * (height - 8) - 4]);
|
||
const path = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||
const area = `${path} L${width},${height} L0,${height} Z`;
|
||
const id = `sg-${color.replace(/[^a-z0-9]/gi, '')}-${width}`;
|
||
return (
|
||
<svg viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" style={{ width: '100%', height: '100%' }}>
|
||
<defs>
|
||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
|
||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
{fill && <path d={area} fill={`url(#${id})`} />}
|
||
<path d={path} fill="none" stroke={color} strokeWidth={strokeW} strokeLinecap="round" strokeLinejoin="round" />
|
||
<circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r={strokeW + 1} fill={color} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function Donut({ value, size = 220, stroke = 16, color = ACCENT, label, sublabel, animateKey }) {
|
||
const animated = useCountUp(value, 1400, [animateKey, value]);
|
||
const r = (size - stroke) / 2;
|
||
const c = 2 * Math.PI * r;
|
||
const off = c - (animated / 100) * c;
|
||
return (
|
||
<div style={{ position: 'relative', display: 'inline-block', width: size, height: size }}>
|
||
<svg width={size} height={size}>
|
||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={stroke} />
|
||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={color} strokeWidth={stroke}
|
||
strokeDasharray={c} strokeDashoffset={off} strokeLinecap="round"
|
||
transform={`rotate(-90 ${size/2} ${size/2})`}
|
||
style={{ filter: `drop-shadow(0 0 12px ${color}55)` }} />
|
||
</svg>
|
||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center' }}>
|
||
<div style={{ fontSize: size < 260 ? 44 : 56, fontWeight: 700, letterSpacing: -2, lineHeight: 1, fontFeatureSettings: '"tnum" 1' }}>
|
||
{Math.round(animated)}<span style={{ fontSize: size < 260 ? 24 : 32, opacity: 0.6, marginLeft: 3 }}>%</span>
|
||
</div>
|
||
{label && <div style={{ fontSize: 12, color: 'rgba(244,245,247,0.62)', marginTop: 5, letterSpacing: 1.5, textTransform: 'uppercase' }}>{label}</div>}
|
||
{sublabel && <div style={{ fontSize: 11, color: 'rgba(244,245,247,0.38)', marginTop: 2 }}>{sublabel}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BigNumber({ value, suffix = '', decimals = 0, animateKey }) {
|
||
const animated = useCountUp(value, 1400, [animateKey, value]);
|
||
const display = decimals === 0
|
||
? Math.round(animated).toLocaleString('de-DE')
|
||
: animated.toFixed(decimals).replace('.', ',');
|
||
return <span key={animateKey} className="tv-number-punch" style={{ fontFeatureSettings: '"tnum" 1' }}>{display}{suffix}</span>;
|
||
}
|
||
|
||
function StatusDot({ status, size = 10 }) {
|
||
const colors = { ok: C_OK, online: C_OK, warn: C_WARN, error: C_ERROR, offline: C_ERROR, info: C_INFO };
|
||
const c = colors[status] || '#94A3B8';
|
||
const pulse = status === 'ok' || status === 'online';
|
||
return (
|
||
<span className={pulse ? 'tv-dot-pulse' : ''} style={{
|
||
display: 'inline-block', width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||
background: c, boxShadow: `0 0 ${size}px ${c}88`,
|
||
}} />
|
||
);
|
||
}
|
||
|
||
/* ── Bento tile ─────────────────────────────────────────────── */
|
||
const TILE_GLOWS = {
|
||
hero: 'rgba(79,209,197,0.22)',
|
||
danger: 'rgba(244,63,94,0.22)',
|
||
warn: 'rgba(245,158,11,0.20)',
|
||
info: 'rgba(96,165,250,0.20)',
|
||
success: 'rgba(52,211,153,0.22)',
|
||
};
|
||
const TILE_BG = {
|
||
hero: 'linear-gradient(165deg,rgba(79,209,197,0.16),rgba(79,209,197,0.025) 60%,rgba(255,255,255,0.012))',
|
||
danger: 'linear-gradient(165deg,rgba(244,63,94,0.16),rgba(244,63,94,0.025) 60%,rgba(255,255,255,0.012))',
|
||
warn: 'linear-gradient(165deg,rgba(245,158,11,0.14),rgba(245,158,11,0.025) 60%,rgba(255,255,255,0.012))',
|
||
info: 'linear-gradient(165deg,rgba(96,165,250,0.14),rgba(96,165,250,0.025) 60%,rgba(255,255,255,0.012))',
|
||
success: 'linear-gradient(165deg,rgba(52,211,153,0.14),rgba(52,211,153,0.025) 60%,rgba(255,255,255,0.012))',
|
||
'': 'linear-gradient(180deg,rgba(255,255,255,0.045),rgba(255,255,255,0.012))',
|
||
};
|
||
const TILE_BORDER = {
|
||
hero: 'rgba(79,209,197,0.22)', danger: 'rgba(244,63,94,0.22)',
|
||
warn: 'rgba(245,158,11,0.22)', info: 'rgba(96,165,250,0.22)',
|
||
success: 'rgba(52,211,153,0.22)', '': 'rgba(255,255,255,0.07)',
|
||
};
|
||
|
||
function BTile({ children, style, variant = '', animDelay = 0, cornerGlyph }) {
|
||
return (
|
||
<div style={{
|
||
borderRadius: 18, padding: 20,
|
||
background: TILE_BG[variant] || TILE_BG[''],
|
||
border: `1px solid ${TILE_BORDER[variant] || TILE_BORDER['']}`,
|
||
display: 'flex', flexDirection: 'column',
|
||
position: 'relative', overflow: 'hidden', isolation: 'isolate',
|
||
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.05), 0 1px 0 rgba(255,255,255,0.02), 0 20px 60px rgba(0,0,0,0.35)${TILE_GLOWS[variant] ? `, 0 0 0 1px transparent` : ''}`,
|
||
animation: `tvTileIn 800ms ${animDelay}ms both cubic-bezier(.2,.7,.2,1)`,
|
||
...style,
|
||
}}>
|
||
{/* hairline */}
|
||
<div style={{ content: '""', position: 'absolute', top: 0, left: '12%', right: '12%', height: 1, background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.22), transparent)', pointerEvents: 'none' }} />
|
||
{/* glow */}
|
||
{TILE_GLOWS[variant] && (
|
||
<div style={{ position: 'absolute', inset: -1, borderRadius: 18, pointerEvents: 'none', zIndex: -1, background: `radial-gradient(ellipse at 50% 0%, ${TILE_GLOWS[variant]}, transparent 60%)`, opacity: 0.6 }} />
|
||
)}
|
||
{cornerGlyph && (
|
||
<div style={{ position: 'absolute', top: 14, right: 16, opacity: 0.6, fontSize: 10, letterSpacing: 2, textTransform: 'uppercase', color: 'rgba(244,245,247,0.38)', fontFeatureSettings: '"tnum" 1' }}>
|
||
{cornerGlyph}
|
||
</div>
|
||
)}
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BLabel({ children, style }) {
|
||
return <div style={{ fontSize: 11, letterSpacing: 2.2, textTransform: 'uppercase', color: 'rgba(244,245,247,0.38)', fontWeight: 600, ...style }}>{children}</div>;
|
||
}
|
||
|
||
function SlideHeader({ eyebrow, title, accentSpan, right }) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 14 }}>
|
||
<div>
|
||
<div style={{ fontSize: 11, letterSpacing: 3.2, textTransform: 'uppercase', color: 'rgba(244,245,247,0.38)', fontWeight: 600, marginBottom: 8 }}>{eyebrow}</div>
|
||
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -1.8, lineHeight: 1 }}>
|
||
{title}{accentSpan && <> <span style={{
|
||
background: `linear-gradient(180deg, ${ACCENT} 0%, color-mix(in srgb, ${ACCENT}, white 18%) 100%)`,
|
||
WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent',
|
||
filter: `drop-shadow(0 0 24px color-mix(in srgb, ${ACCENT}, transparent 60%))`,
|
||
}}>{accentSpan}</span></>}
|
||
</div>
|
||
</div>
|
||
{right}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BentoGrid({ children, style }) {
|
||
return (
|
||
<div style={{
|
||
display: 'grid', gap: 14,
|
||
gridTemplateColumns: 'repeat(12, 1fr)',
|
||
gridAutoRows: 'minmax(0, 1fr)',
|
||
flex: 1, minHeight: 0,
|
||
...style,
|
||
}}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SlideWrap({ k, tint, children }) {
|
||
return (
|
||
<div key={k} style={{ position: 'absolute', inset: 0, animation: 'tvSlideUp 900ms cubic-bezier(.2,.7,.2,1)' }}>
|
||
<MeshBackground tint={tint} />
|
||
<div style={{ position: 'absolute', inset: 0, padding: '168px 60px 62px', display: 'flex', flexDirection: 'column' }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════
|
||
DATA PROCESSING
|
||
════════════════════════════════════════════════════════════ */
|
||
function processData(data) {
|
||
const now = Date.now();
|
||
const rawAgents = data.agentList || [];
|
||
const agentList = rawAgents.map(a => {
|
||
const ms = a.last_checkin ? new Date(a.last_checkin + 'Z').getTime() : 0;
|
||
const online = ms && (now - ms) < 15 * 60 * 1000;
|
||
return { name: a.hostname || '?', cpu: a.cpu_usage_percent || 0, ram: a.ram_usage_percent || 0, status: online ? 'online' : 'offline' };
|
||
});
|
||
const agentsOnline = agentList.filter(a => a.status === 'online');
|
||
const avgCpu = agentsOnline.length > 0 ? agentsOnline.reduce((s, a) => s + a.cpu, 0) / agentsOnline.length : 0;
|
||
// Synthetic 24h CPU trend
|
||
const cpuTrend24h = Array.from({ length: 24 }, (_, i) => {
|
||
const t = i / 23;
|
||
return Math.max(0, Math.min(100, avgCpu * (0.5 + t * 0.5) + Math.sin(i * 0.8) * 6));
|
||
});
|
||
|
||
const total = agentList.length;
|
||
const online = agentsOnline.length;
|
||
const offline = total - online;
|
||
|
||
const patch = data.patch || {};
|
||
const patchTotal = patch.total || total || 1;
|
||
const compliancePct = Math.round(((patch.compliant || 0) / patchTotal) * 100);
|
||
|
||
const noEncrypt = data.monitoring?.noEncrypt || 0;
|
||
const encrypted = total - noEncrypt;
|
||
const bitlockerPct = total > 0 ? Math.round((encrypted / total) * 100) : 0;
|
||
|
||
const secReport = data.lastSecReport;
|
||
const loginFailures = secReport?.analysis?.login_stats?.failed || 0;
|
||
// Synthetic 14-day login failure trend
|
||
const failuresTrend14d = Array.from({ length: 14 }, (_, i) => {
|
||
const base = loginFailures;
|
||
return Math.max(0, Math.round(base * (0.3 + i * 0.05) + Math.sin(i * 0.9) * base * 0.12));
|
||
});
|
||
|
||
const tickets = data.tickets || {};
|
||
const ticketPerDay7 = data.ticketMetrics?.perDay7 || data.ticketMetrics?.daily?.slice(-7)?.map(d => d.count) || [0, 0, 0, 0, 0, 0, 0];
|
||
|
||
return {
|
||
agentList,
|
||
agents: { online, offline, total, cpuTrend24h },
|
||
patches: {
|
||
compliancePct,
|
||
current: patch.compliant || 0,
|
||
warning: patch.warnings || 0,
|
||
offline: patch.offline || 0,
|
||
pending: patch.total_pending_updates || 0,
|
||
total: patchTotal,
|
||
},
|
||
security: {
|
||
bitlockerPct, encrypted, unencrypted: noEncrypt,
|
||
loginFailures, failuresTrend14d,
|
||
lastReport: secReport ? new Date(secReport.created_at).toLocaleDateString('de-DE') : '—',
|
||
risk: (loginFailures > 5000 || noEncrypt > 5) ? 'HOCH' : (loginFailures > 1000 || noEncrypt > 1) ? 'MITTEL' : 'NIEDRIG',
|
||
},
|
||
tickets: {
|
||
open: tickets.open || 0,
|
||
inProgress: tickets.inProgress || 0,
|
||
solvedToday: tickets.closedToday || 0,
|
||
byPriority: { critical: tickets.critical || 0, high: tickets.high || 0, medium: tickets.medium || 0, low: tickets.low || 0 },
|
||
perDay7: ticketPerDay7,
|
||
},
|
||
// Static: network, backup, CVE (no backend endpoint yet)
|
||
network: [
|
||
{ name: 'Internet', status: 'ok', latency: '12 ms', detail: '1 Gbit/s' },
|
||
{ name: 'VPN', status: 'ok', latency: '24 ms', detail: 'Aktive Sessions' },
|
||
{ name: 'Exchange Online', status: 'ok', latency: '48 ms', detail: 'Microsoft 365' },
|
||
{ name: 'SharePoint', status: 'ok', latency: '52 ms', detail: 'OK' },
|
||
{ name: 'Proxmox Cluster', status: 'ok', latency: '3 ms', detail: '3 Nodes' },
|
||
{ name: 'NoSpamProxy', status: 'ok', latency: '18 ms', detail: 'OK' },
|
||
{ name: 'Swyx Telefonie', status: 'ok', latency: '9 ms', detail: 'OK' },
|
||
{ name: 'SelectLine ERP', status: 'ok', latency: '14 ms', detail: 'OK' },
|
||
],
|
||
backups: {
|
||
coverage: 96, successful: 42, failed: 1, running: 0,
|
||
lastSuccess: '06:14',
|
||
lastFailJob: 'FAM102223 · Vollsicherung',
|
||
trend14d: [98,99,100,100,97,95,100,100,99,98,100,96,94,96],
|
||
},
|
||
cves: [
|
||
{ id: 'CVE-2026-13421', sev: 'critical', cvss: 9.8, title: 'Apache Tomcat — Pre-Auth RCE via AJP-Connector', vendor: 'Apache', published: '2 Std.' },
|
||
{ id: 'CVE-2026-13380', sev: 'critical', cvss: 9.6, title: 'Microsoft Outlook — Zero-Click via Preview Pane', vendor: 'Microsoft', published: '5 Std.' },
|
||
{ id: 'CVE-2026-13298', sev: 'high', cvss: 8.4, title: 'Fortinet FortiGate — Auth Bypass', vendor: 'Fortinet', published: '11 Std.' },
|
||
{ id: 'CVE-2026-13211', sev: 'high', cvss: 7.9, title: 'VMware ESXi — Privilege Escalation', vendor: 'VMware', published: '18 Std.' },
|
||
{ id: 'CVE-2026-13104', sev: 'medium', cvss: 6.5, title: 'Chrome V8 — Type Confusion', vendor: 'Google', published: '1 Tag' },
|
||
],
|
||
};
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════
|
||
SLIDES
|
||
════════════════════════════════════════════════════════════ */
|
||
|
||
/* 1 – Overview */
|
||
function SlideOverview({ d, k }) {
|
||
const onlinePct = d.agents.total > 0 ? Math.round((d.agents.online / d.agents.total) * 100) : 0;
|
||
return (
|
||
<SlideWrap k={k} tint="neutral">
|
||
<SlideHeader eyebrow="Cereda Systems GmbH · Echtzeit-Systemübersicht" title="Heute im" accentSpan="Überblick" />
|
||
<BentoGrid>
|
||
{/* Hero — Agents */}
|
||
<BTile variant="hero" style={{ gridColumn: 'span 7', gridRow: 'span 4' }} animDelay={0} cornerGlyph="01 · AGENTS">
|
||
<BLabel>Agents online</BLabel>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 14, marginTop: 10 }}>
|
||
<span style={{ fontSize: 160, fontWeight: 700, letterSpacing: -5, lineHeight: 0.92, color: ACCENT, filter: `drop-shadow(0 0 32px ${ACCENT}60)`, fontFeatureSettings: '"tnum" 1' }}>
|
||
<BigNumber value={d.agents.online} animateKey={k} />
|
||
</span>
|
||
<span style={{ fontSize: 52, color: 'rgba(244,245,247,0.18)', fontWeight: 500, letterSpacing: -1.5, fontFeatureSettings: '"tnum" 1' }}>/ {d.agents.total}</span>
|
||
</div>
|
||
{/* Fleet strip */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(d.agentList.length, 1)}, 1fr)`, gap: 4, marginTop: 12 }}>
|
||
{d.agentList.map((a, i) => (
|
||
<div key={a.name} style={{
|
||
aspectRatio: 1, borderRadius: 6,
|
||
border: '1px solid rgba(255,255,255,0.04)',
|
||
background: a.status === 'online'
|
||
? `rgba(79,209,197,${0.3 + (a.cpu / 100) * 0.6})`
|
||
: 'rgba(239,68,68,0.4)',
|
||
animation: `tvHeatIn 600ms ${300 + i * 14}ms both cubic-bezier(.2,.7,.2,1)`,
|
||
}} />
|
||
))}
|
||
</div>
|
||
<div style={{ marginTop: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||
<div style={{ display: 'flex', gap: 24 }}>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}><StatusDot status="online" size={9} /><span style={{ color: 'rgba(244,245,247,0.62)' }}>{d.agents.online} online</span></span>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}><StatusDot status="offline" size={9} /><span style={{ color: 'rgba(244,245,247,0.62)' }}>{d.agents.offline} offline</span></span>
|
||
</div>
|
||
<div style={{ fontSize: 28, fontWeight: 600, color: ACCENT, letterSpacing: -0.5, fontFeatureSettings: '"tnum" 1' }}>{onlinePct} %</div>
|
||
</div>
|
||
</BTile>
|
||
|
||
{/* Tickets */}
|
||
<BTile variant="success" style={{ gridColumn: 'span 5', gridRow: 'span 2' }} animDelay={140} cornerGlyph="02 · HELPDESK">
|
||
<BLabel>Offene Tickets</BLabel>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginTop: 8 }}>
|
||
<span style={{ fontSize: 80, fontWeight: 700, letterSpacing: -2.5, lineHeight: 1, color: C_OK }}><BigNumber value={d.tickets.open} animateKey={k} /></span>
|
||
<div style={{ width: 180, height: 40 }}><Sparkline data={d.tickets.perDay7} color={C_OK} width={180} height={40} strokeW={1.5} /></div>
|
||
</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 14, color: 'rgba(244,245,247,0.62)' }}>
|
||
{d.tickets.byPriority.critical} kritisch · {d.tickets.byPriority.high} hoch
|
||
</div>
|
||
</BTile>
|
||
|
||
{/* Security */}
|
||
<BTile variant="danger" style={{ gridColumn: 'span 5', gridRow: 'span 2' }} animDelay={200} cornerGlyph="03 · SECURITY">
|
||
<BLabel>Login-Fehler · 30 Tage</BLabel>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginTop: 8 }}>
|
||
<span style={{ fontSize: 64, fontWeight: 700, letterSpacing: -2, lineHeight: 1, color: C_ERROR }}><BigNumber value={d.security.loginFailures} animateKey={k} /></span>
|
||
<div style={{ width: 180, height: 40 }}><Sparkline data={d.security.failuresTrend14d} color={C_ERROR} width={180} height={40} strokeW={1.5} /></div>
|
||
</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 14, color: 'rgba(244,245,247,0.62)' }}><span style={{ color: C_ERROR }}>●</span> Risiko: {d.security.risk}</div>
|
||
</BTile>
|
||
|
||
{/* Updates */}
|
||
<BTile variant="info" style={{ gridColumn: 'span 4', gridRow: 'span 2' }} animDelay={260} cornerGlyph="04 · UPDATES">
|
||
<BLabel>Updates ausstehend</BLabel>
|
||
<div style={{ fontSize: 80, fontWeight: 700, letterSpacing: -2.5, lineHeight: 1, color: C_INFO, marginTop: 8 }}><BigNumber value={d.patches.pending} animateKey={k} /></div>
|
||
<div style={{ height: 8, borderRadius: 4, background: 'rgba(255,255,255,0.06)', overflow: 'hidden', marginTop: 'auto' }}>
|
||
<div style={{ height: '100%', borderRadius: 4, background: C_INFO, transition: 'width 1.4s cubic-bezier(.2,.7,.2,1)', width: `${d.patches.compliancePct}%` }} />
|
||
</div>
|
||
<div style={{ marginTop: 6, fontSize: 12, color: 'rgba(244,245,247,0.62)' }}>{d.patches.compliancePct} % konform</div>
|
||
</BTile>
|
||
|
||
{/* Backup */}
|
||
<BTile variant="success" style={{ gridColumn: 'span 4', gridRow: 'span 2' }} animDelay={320} cornerGlyph="05 · BACKUP">
|
||
<BLabel>Backup Coverage</BLabel>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginTop: 8 }}>
|
||
<span style={{ fontSize: 80, fontWeight: 700, letterSpacing: -2.5, lineHeight: 1, color: C_OK }}><BigNumber value={d.backups.coverage} animateKey={k} /></span>
|
||
<span style={{ fontSize: 36, color: 'rgba(244,245,247,0.18)' }}>%</span>
|
||
</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 13, color: 'rgba(244,245,247,0.62)' }}>Letzter Lauf {d.backups.lastSuccess} Uhr</div>
|
||
</BTile>
|
||
|
||
{/* CVE */}
|
||
<BTile variant="warn" style={{ gridColumn: 'span 4', gridRow: 'span 2' }} animDelay={380} cornerGlyph="06 · CVE">
|
||
<BLabel>Kritische CVEs</BLabel>
|
||
<div style={{ fontSize: 80, fontWeight: 700, letterSpacing: -2.5, lineHeight: 1, color: C_WARN, marginTop: 8 }}>
|
||
{d.cves.filter(c => c.sev === 'critical').length}
|
||
</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 13, color: 'rgba(244,245,247,0.62)', lineHeight: 1.3 }}>
|
||
{d.cves[0].vendor} · {d.cves[0].id}
|
||
</div>
|
||
</BTile>
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* 2 – Agents */
|
||
function SlideAgents({ d, k }) {
|
||
const pct = d.agents.total > 0 ? Math.round((d.agents.online / d.agents.total) * 100) : 0;
|
||
const onlineAgents = d.agentList.filter(a => a.status === 'online');
|
||
const top = [...onlineAgents].sort((a, b) => b.cpu - a.cpu).slice(0, 6);
|
||
const avgCpu = onlineAgents.length > 0 ? Math.round(onlineAgents.reduce((s, a) => s + a.cpu, 0) / onlineAgents.length) : 0;
|
||
return (
|
||
<SlideWrap k={k} tint="mint">
|
||
<SlideHeader eyebrow="Windows Agents · Live-Telemetrie" title="Flotte" accentSpan="im Blick" />
|
||
<BentoGrid>
|
||
<BTile variant="hero" style={{ gridColumn: 'span 4', gridRow: 'span 5' }} animDelay={0} cornerGlyph="VERFÜGBARKEIT">
|
||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 8 }}>
|
||
<Donut value={pct} size={240} stroke={16} color={ACCENT} animateKey={k} label="Online" sublabel={`${d.agents.online} / ${d.agents.total}`} />
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}><StatusDot status="online" size={9} /><span style={{ color: 'rgba(244,245,247,0.62)' }}>{d.agents.online} online</span></span>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}><StatusDot status="offline" size={9} /><span style={{ color: 'rgba(244,245,247,0.62)' }}>{d.agents.offline} offline</span></span>
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile style={{ gridColumn: 'span 8', gridRow: 'span 3' }} animDelay={120} cornerGlyph="CPU · 24 STD">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||
<BLabel>Cluster-CPU-Last</BLabel>
|
||
<div style={{ fontSize: 14, color: 'rgba(244,245,247,0.62)', fontFeatureSettings: '"tnum" 1' }}>
|
||
Ø <span style={{ color: ACCENT, fontWeight: 600 }}>{avgCpu} %</span> · jetzt {Math.round(d.agents.cpuTrend24h[d.agents.cpuTrend24h.length - 1])} %
|
||
</div>
|
||
</div>
|
||
<div style={{ flex: 1, marginTop: 12, minHeight: 0 }}>
|
||
<Sparkline data={d.agents.cpuTrend24h} color={ACCENT} width={900} height={200} />
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'rgba(244,245,247,0.38)', marginTop: 8 }}>
|
||
<span>vor 24 Std</span><span>jetzt</span>
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile style={{ gridColumn: 'span 8', gridRow: 'span 2' }} animDelay={200} cornerGlyph="TOP 6 · CPU">
|
||
<BLabel>Top Auslastung</BLabel>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 16, marginTop: 12, flex: 1 }}>
|
||
{(top.length > 0 ? top : Array(6).fill({ name: '—', cpu: 0 })).map((a, i) => (
|
||
<div key={a.name + i} style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', gap: 6,
|
||
animation: `tvTileIn 600ms ${260 + i * 60}ms both cubic-bezier(.2,.7,.2,1)` }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, opacity: 0.85, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.name}</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700, letterSpacing: -0.5, color: a.cpu > 50 ? C_WARN : ACCENT, fontFeatureSettings: '"tnum" 1' }}>{a.cpu.toFixed(1)} %</div>
|
||
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.06)', overflow: 'hidden' }}>
|
||
<div style={{ height: '100%', borderRadius: 2, transition: 'width 1.4s cubic-bezier(.2,.7,.2,1)', width: `${a.cpu}%`, background: a.cpu > 50 ? C_WARN : ACCENT }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</BTile>
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* 3 – Patch Compliance */
|
||
function SlideCompliance({ d, k }) {
|
||
const pct = d.patches.compliancePct;
|
||
const color = pct < 50 ? C_ERROR : pct < 80 ? C_WARN : C_OK;
|
||
return (
|
||
<SlideWrap k={k} tint="red">
|
||
<SlideHeader eyebrow="Patch Compliance" title="Update-" accentSpan="Zustand"
|
||
right={<div style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderRadius: 999,
|
||
background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.35)', color: '#FCD34D',
|
||
fontSize: 18, fontWeight: 600, letterSpacing: 0.5,
|
||
}}>⚠ unter Ziel · 95 %</div>}
|
||
/>
|
||
<BentoGrid>
|
||
<BTile variant="danger" style={{ gridColumn: 'span 5', gridRow: 'span 4' }} animDelay={0} cornerGlyph="KONFORM">
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Donut value={pct} size={270} stroke={20} color={color} animateKey={k} label="konform" sublabel={`${d.patches.current} von ${d.patches.total} Geräten`} />
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'rgba(244,245,247,0.38)' }}>
|
||
<span>Ziel: 95 %</span>
|
||
<span style={{ color: C_ERROR }}>Abweichung −{Math.max(0, 95 - pct)} %</span>
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile variant="success" style={{ gridColumn: 'span 7', gridRow: 'span 2' }} animDelay={120} cornerGlyph="AKTUELL">
|
||
<BLabel>Aktuell · konform</BLabel>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginTop: 8 }}>
|
||
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: C_OK }}><BigNumber value={d.patches.current} animateKey={k} /></span>
|
||
<div style={{ fontSize: 14, color: 'rgba(244,245,247,0.62)', fontFeatureSettings: '"tnum" 1' }}>{d.patches.total > 0 ? Math.round((d.patches.current / d.patches.total) * 100) : 0} %</div>
|
||
</div>
|
||
<div style={{ height: 8, borderRadius: 4, background: 'rgba(255,255,255,0.06)', overflow: 'hidden', marginTop: 'auto' }}>
|
||
<div style={{ height: '100%', borderRadius: 4, background: C_OK, transition: 'width 1.4s cubic-bezier(.2,.7,.2,1)', width: `${d.patches.total > 0 ? (d.patches.current / d.patches.total) * 100 : 0}%` }} />
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile variant="warn" style={{ gridColumn: 'span 4', gridRow: 'span 2' }} animDelay={180} cornerGlyph="FEHLEN">
|
||
<BLabel>Updates fehlen</BLabel>
|
||
<div style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: C_WARN, marginTop: 8 }}><BigNumber value={d.patches.warning} animateKey={k} /></div>
|
||
<div style={{ height: 8, borderRadius: 4, background: 'rgba(255,255,255,0.06)', overflow: 'hidden', marginTop: 'auto' }}>
|
||
<div style={{ height: '100%', borderRadius: 4, background: C_WARN, transition: 'width 1.4s cubic-bezier(.2,.7,.2,1)', width: `${d.patches.total > 0 ? (d.patches.warning / d.patches.total) * 100 : 0}%` }} />
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile variant="danger" style={{ gridColumn: 'span 3', gridRow: 'span 2' }} animDelay={240} cornerGlyph="OFFLINE">
|
||
<BLabel>Offline</BLabel>
|
||
<div style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: C_ERROR, marginTop: 8 }}><BigNumber value={d.patches.offline} animateKey={k} /></div>
|
||
<div style={{ height: 8, borderRadius: 4, background: 'rgba(255,255,255,0.06)', overflow: 'hidden', marginTop: 'auto' }}>
|
||
<div style={{ height: '100%', borderRadius: 4, background: C_ERROR, transition: 'width 1.4s cubic-bezier(.2,.7,.2,1)', width: `${d.patches.total > 0 ? (d.patches.offline / d.patches.total) * 100 : 0}%` }} />
|
||
</div>
|
||
</BTile>
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* 4 – Security */
|
||
function SlideSecurity({ d, k }) {
|
||
return (
|
||
<SlideWrap k={k} tint="red">
|
||
<SlideHeader eyebrow="Sicherheit" title="Risiko" accentSpan="Übersicht"
|
||
right={<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, padding: '8px 16px', borderRadius: 999, background: 'rgba(244,63,94,0.12)', border: '1px solid rgba(244,63,94,0.35)', color: '#FCA5A5', fontSize: 15, fontWeight: 600 }}>⚠ Risiko {d.security.risk}</div>}
|
||
/>
|
||
<BentoGrid>
|
||
<BTile variant="danger" style={{ gridColumn: 'span 7', gridRow: 'span 4' }} animDelay={0} cornerGlyph="M365 · 30 TAGE">
|
||
<BLabel>Login-Fehler</BLabel>
|
||
<div style={{ fontSize: 130, fontWeight: 700, color: C_ERROR, letterSpacing: -4, lineHeight: 0.95, marginTop: 8,
|
||
filter: 'drop-shadow(0 0 32px rgba(244,63,94,0.4))', fontFeatureSettings: '"tnum" 1' }}>
|
||
<BigNumber value={d.security.loginFailures} animateKey={k} />
|
||
</div>
|
||
<div style={{ fontSize: 16, color: 'rgba(244,245,247,0.62)', marginTop: 4 }}>fehlgeschlagene Anmeldeversuche · Microsoft 365</div>
|
||
<div style={{ flex: 1, marginTop: 20, minHeight: 0 }}>
|
||
<Sparkline data={d.security.failuresTrend14d} color={C_ERROR} width={900} height={140} />
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'rgba(244,245,247,0.38)' }}>
|
||
<span>vor 14 Tagen</span><span>heute · {d.security.failuresTrend14d[d.security.failuresTrend14d.length - 1]}/Tag</span>
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile variant="warn" style={{ gridColumn: 'span 5', gridRow: 'span 2' }} animDelay={120} cornerGlyph="BITLOCKER">
|
||
<BLabel>Verschlüsselungs-Abdeckung</BLabel>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginTop: 8 }}>
|
||
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: d.security.bitlockerPct >= 90 ? C_OK : C_WARN }}><BigNumber value={d.security.bitlockerPct} suffix=" %" animateKey={k} /></span>
|
||
<span style={{ fontSize: 14, color: 'rgba(244,245,247,0.62)', fontFeatureSettings: '"tnum" 1' }}>{d.security.encrypted}/{d.agents.total}</span>
|
||
</div>
|
||
<div style={{ height: 8, borderRadius: 4, background: 'rgba(255,255,255,0.06)', overflow: 'hidden', marginTop: 'auto' }}>
|
||
<div style={{ height: '100%', borderRadius: 4, background: d.security.bitlockerPct >= 90 ? C_OK : C_WARN, transition: 'width 1.4s cubic-bezier(.2,.7,.2,1)', width: `${d.security.bitlockerPct}%` }} />
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile variant="danger" style={{ gridColumn: 'span 3', gridRow: 'span 2' }} animDelay={180} cornerGlyph="OFFEN">
|
||
<BLabel>Unverschlüsselt</BLabel>
|
||
<div style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: C_ERROR, marginTop: 8 }}><BigNumber value={d.security.unencrypted} animateKey={k} /></div>
|
||
<div style={{ marginTop: 'auto', fontSize: 12, color: 'rgba(244,245,247,0.62)' }}>BitLocker deaktiviert</div>
|
||
</BTile>
|
||
|
||
<BTile style={{ gridColumn: 'span 2', gridRow: 'span 2' }} animDelay={240} cornerGlyph="LETZTER">
|
||
<BLabel>Report</BLabel>
|
||
<div style={{ fontSize: 22, fontWeight: 700, marginTop: 8, letterSpacing: -0.5, fontFeatureSettings: '"tnum" 1' }}>{d.security.lastReport}</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 12, color: 'rgba(244,245,247,0.62)' }}>SIEM · auto</div>
|
||
</BTile>
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* 5 – Helpdesk */
|
||
function SlideHelpdesk({ d, k }) {
|
||
const prios = [
|
||
{ label: 'Kritisch', v: d.tickets.byPriority.critical, c: C_ERROR },
|
||
{ label: 'Hoch', v: d.tickets.byPriority.high, c: C_WARN },
|
||
{ label: 'Mittel', v: d.tickets.byPriority.medium, c: C_INFO },
|
||
{ label: 'Niedrig', v: d.tickets.byPriority.low, c: 'rgba(244,245,247,0.62)' },
|
||
];
|
||
return (
|
||
<SlideWrap k={k} tint="mint">
|
||
<SlideHeader eyebrow="Helpdesk" title="Saubere" accentSpan="Inbox" />
|
||
<BentoGrid>
|
||
<BTile variant="success" style={{ gridColumn: 'span 7', gridRow: 'span 4' }} animDelay={0} cornerGlyph="OFFEN · GESAMT">
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center' }}>
|
||
<div style={{ fontSize: 200, fontWeight: 700, color: C_OK, lineHeight: 0.92, letterSpacing: -7,
|
||
filter: 'drop-shadow(0 0 40px rgba(52,211,153,0.35))', fontFeatureSettings: '"tnum" 1' }}>
|
||
<BigNumber value={d.tickets.open} animateKey={k} />
|
||
</div>
|
||
<div style={{ marginTop: 16, fontSize: 18, color: 'rgba(244,245,247,0.62)' }}>offene Tickets · {d.tickets.byPriority.critical === 0 ? 'keine Eskalationen' : `${d.tickets.byPriority.critical} kritisch`}</div>
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile style={{ gridColumn: 'span 5', gridRow: 'span 2' }} animDelay={120} cornerGlyph="PRIORITÄT">
|
||
<BLabel>Verteilung</BLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12, flex: 1, justifyContent: 'space-around' }}>
|
||
{prios.map(p => (
|
||
<div key={p.label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<span style={{ width: 8, height: 8, borderRadius: 99, background: p.c, boxShadow: `0 0 8px ${p.c}88` }} />
|
||
<span style={{ fontSize: 16, fontWeight: 500 }}>{p.label}</span>
|
||
</span>
|
||
<span style={{ fontSize: 22, fontWeight: 700, color: p.c, fontFeatureSettings: '"tnum" 1' }}><BigNumber value={p.v} animateKey={k} /></span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile style={{ gridColumn: 'span 5', gridRow: 'span 2' }} animDelay={180} cornerGlyph="VERLAUF · 7 T">
|
||
<BLabel>Tickets pro Tag</BLabel>
|
||
<div style={{ flex: 1, marginTop: 12 }}>
|
||
<Sparkline data={d.tickets.perDay7.length > 0 ? d.tickets.perDay7 : [0, 0, 0, 0, 0, 0, 0]} color={ACCENT} width={500} height={130} />
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'rgba(244,245,247,0.38)' }}>
|
||
{['Mo','Di','Mi','Do','Fr','Sa','So'].map(d => <span key={d}>{d}</span>)}
|
||
</div>
|
||
</BTile>
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* 6 – Network */
|
||
function SlideNetwork({ d, k }) {
|
||
const trends = d.network.map((s, i) => Array.from({ length: 12 }, (_, j) => {
|
||
const base = parseInt(s.latency, 10) || 20;
|
||
return base + Math.sin(j * 0.6 + i) * (s.status === 'warn' ? 80 : 10) + (j % 3) * 4;
|
||
}));
|
||
const okCount = d.network.filter(s => s.status === 'ok').length;
|
||
const warnCount = d.network.filter(s => s.status === 'warn').length;
|
||
return (
|
||
<SlideWrap k={k} tint="blue">
|
||
<SlideHeader eyebrow="Netzwerk & Services" title="Konnek-" accentSpan="tivität"
|
||
right={<div style={{ fontSize: 14, color: 'rgba(244,245,247,0.62)', textAlign: 'right' }}>
|
||
<div><span style={{ color: C_OK }}>● </span>{okCount} OK</div>
|
||
{warnCount > 0 && <div><span style={{ color: C_WARN }}>● </span>{warnCount} Warnung</div>}
|
||
</div>}
|
||
/>
|
||
<BentoGrid>
|
||
{d.network.map((s, i) => {
|
||
const color = s.status === 'ok' ? C_OK : s.status === 'warn' ? C_WARN : C_ERROR;
|
||
const variant = s.status === 'warn' ? 'warn' : s.status === 'error' ? 'danger' : '';
|
||
return (
|
||
<BTile key={s.name} variant={variant} style={{ gridColumn: 'span 3', gridRow: 'span 2' }} animDelay={i * 60}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<StatusDot status={s.status} size={10} />
|
||
<div style={{ fontSize: 17, fontWeight: 600 }}>{s.name}</div>
|
||
</div>
|
||
<div style={{ width: 70, height: 22, opacity: 0.7 }}>
|
||
<Sparkline data={trends[i]} color={color} width={70} height={22} fill={false} strokeW={1.5} />
|
||
</div>
|
||
</div>
|
||
<div style={{ fontSize: 30, fontWeight: 700, color, letterSpacing: -0.5, fontFeatureSettings: '"tnum" 1' }}>{s.latency}</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 12, color: 'rgba(244,245,247,0.62)' }}>{s.detail}</div>
|
||
</BTile>
|
||
);
|
||
})}
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* 7 – Backup */
|
||
function SlideBackup({ d, k }) {
|
||
return (
|
||
<SlideWrap k={k} tint="mint">
|
||
<SlideHeader eyebrow="Backup · Datenschutz im Hintergrund" title="24 h" accentSpan="im Grünen" />
|
||
<BentoGrid>
|
||
<BTile variant="hero" style={{ gridColumn: 'span 5', gridRow: 'span 4' }} animDelay={0} cornerGlyph="ABDECKUNG">
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Donut value={d.backups.coverage} size={240} stroke={18} color={C_OK} animateKey={k} label="Erfolgreich" />
|
||
</div>
|
||
<div style={{ textAlign: 'center', fontSize: 14, color: 'rgba(244,245,247,0.62)' }}>Letzte Sicherung um {d.backups.lastSuccess} Uhr</div>
|
||
</BTile>
|
||
|
||
<BTile style={{ gridColumn: 'span 7', gridRow: 'span 2' }} animDelay={120} cornerGlyph="14 TAGE">
|
||
<BLabel>Verlauf</BLabel>
|
||
<div style={{ flex: 1, marginTop: 8 }}>
|
||
<Sparkline data={d.backups.trend14d} color={C_OK} width={800} height={150} />
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'rgba(244,245,247,0.38)' }}>
|
||
<span>vor 14 Tagen</span>
|
||
<span>Min {Math.min(...d.backups.trend14d)} % · jetzt {d.backups.trend14d[d.backups.trend14d.length - 1]} %</span>
|
||
</div>
|
||
</BTile>
|
||
|
||
<BTile variant="success" style={{ gridColumn: 'span 3', gridRow: 'span 2' }} animDelay={200} cornerGlyph="OK">
|
||
<BLabel>Erfolgreich</BLabel>
|
||
<div style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: C_OK, marginTop: 8 }}><BigNumber value={d.backups.successful} animateKey={k} /></div>
|
||
<div style={{ marginTop: 'auto', fontSize: 12, color: 'rgba(244,245,247,0.62)' }}>24 Std</div>
|
||
</BTile>
|
||
|
||
<BTile variant="info" style={{ gridColumn: 'span 2', gridRow: 'span 2' }} animDelay={260} cornerGlyph="LIVE">
|
||
<BLabel>Laufend</BLabel>
|
||
<div style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: C_INFO, marginTop: 8 }}><BigNumber value={d.backups.running} animateKey={k} /></div>
|
||
<div style={{ marginTop: 'auto', fontSize: 12, color: 'rgba(244,245,247,0.62)' }}>Jobs aktiv</div>
|
||
</BTile>
|
||
|
||
<BTile variant={d.backups.failed > 0 ? 'warn' : 'success'} style={{ gridColumn: 'span 2', gridRow: 'span 2' }} animDelay={320} cornerGlyph="FAIL">
|
||
<BLabel>Fehler</BLabel>
|
||
<div style={{ fontSize: 48, fontWeight: 700, letterSpacing: -1.2, color: d.backups.failed > 0 ? C_WARN : C_OK, marginTop: 8 }}><BigNumber value={d.backups.failed} animateKey={k} /></div>
|
||
<div style={{ marginTop: 'auto', fontSize: 11, lineHeight: 1.3, color: 'rgba(244,245,247,0.62)' }}>{d.backups.lastFailJob}</div>
|
||
</BTile>
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* 8 – CVE Watch */
|
||
function SlideCVE({ d, k }) {
|
||
const [top, ...rest] = d.cves;
|
||
const sevCount = { critical: 0, high: 0, medium: 0, low: 0 };
|
||
d.cves.forEach(c => sevCount[c.sev] = (sevCount[c.sev] || 0) + 1);
|
||
const sevStyle = {
|
||
critical: { bg: 'rgba(244,63,94,0.15)', color: '#FCA5A5', border: 'rgba(244,63,94,0.3)' },
|
||
high: { bg: 'rgba(245,158,11,0.15)', color: '#FCD34D', border: 'rgba(245,158,11,0.3)' },
|
||
medium: { bg: 'rgba(96,165,250,0.15)', color: '#93C5FD', border: 'rgba(96,165,250,0.3)' },
|
||
low: { bg: 'rgba(148,163,184,0.15)', color: '#CBD5E1', border: 'rgba(148,163,184,0.25)' },
|
||
};
|
||
const SevPill = ({ sev, cvss }) => {
|
||
const s = sevStyle[sev] || sevStyle.low;
|
||
return (
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px', borderRadius: 999, fontSize: 11, fontWeight: 700, letterSpacing: 1, textTransform: 'uppercase', background: s.bg, color: s.color, border: `1px solid ${s.border}` }}>
|
||
{sev} · CVSS {cvss}
|
||
</span>
|
||
);
|
||
};
|
||
return (
|
||
<SlideWrap k={k} tint="violet">
|
||
<SlideHeader eyebrow="CVE Watch · Vendor Advisories" title="Schwach-" accentSpan="stellen"
|
||
right={<div style={{ display: 'flex', gap: 16, fontSize: 13, color: 'rgba(244,245,247,0.62)' }}>
|
||
<span><span style={{ color: '#FCA5A5' }}>● </span>{sevCount.critical || 0} critical</span>
|
||
<span><span style={{ color: '#FCD34D' }}>● </span>{sevCount.high || 0} high</span>
|
||
<span><span style={{ color: '#93C5FD' }}>● </span>{sevCount.medium || 0} medium</span>
|
||
</div>}
|
||
/>
|
||
<BentoGrid>
|
||
<BTile variant="danger" style={{ gridColumn: 'span 12', gridRow: 'span 3' }} animDelay={0} cornerGlyph={`#1 · ${top.id}`}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<SevPill sev={top.sev} cvss={top.cvss} />
|
||
<span style={{ fontSize: 13, color: 'rgba(244,245,247,0.62)' }}>vor {top.published}</span>
|
||
</div>
|
||
<div style={{ fontSize: 38, fontWeight: 600, marginTop: 12, letterSpacing: -0.8, lineHeight: 1.1 }}>
|
||
{top.title}
|
||
</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 15, color: 'rgba(244,245,247,0.62)' }}>{top.vendor} · Sicherheitshinweis</div>
|
||
</BTile>
|
||
{rest.map((c, i) => (
|
||
<BTile key={c.id} variant={c.sev === 'high' ? 'warn' : 'info'} style={{ gridColumn: 'span 6', gridRow: 'span 2' }} animDelay={120 + i * 80} cornerGlyph={c.id}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||
<SevPill sev={c.sev} cvss={c.cvss} />
|
||
<span style={{ fontSize: 12, color: 'rgba(244,245,247,0.62)' }}>vor {c.published}</span>
|
||
</div>
|
||
<div style={{ fontSize: 22, fontWeight: 600, marginTop: 12, letterSpacing: -0.4, lineHeight: 1.2 }}>{c.title}</div>
|
||
<div style={{ marginTop: 'auto', fontSize: 13, color: 'rgba(244,245,247,0.62)' }}>{c.vendor}</div>
|
||
</BTile>
|
||
))}
|
||
</BentoGrid>
|
||
</SlideWrap>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════
|
||
MAIN
|
||
════════════════════════════════════════════════════════════ */
|
||
const SLIDE_DURATION = 12000;
|
||
|
||
const SLIDES = [
|
||
{ id: 'overview', title: 'Übersicht', Component: SlideOverview },
|
||
{ id: 'agents', title: 'Windows Agents', Component: SlideAgents },
|
||
{ id: 'compliance', title: 'Patch Compliance', Component: SlideCompliance },
|
||
{ id: 'security', title: 'Sicherheit', Component: SlideSecurity },
|
||
{ id: 'helpdesk', title: 'Helpdesk', Component: SlideHelpdesk },
|
||
{ id: 'network', title: 'Netzwerk', Component: SlideNetwork },
|
||
{ id: 'backup', title: 'Backup', Component: SlideBackup },
|
||
{ id: 'cve', title: 'CVE Watch', Component: SlideCVE },
|
||
];
|
||
|
||
export default function TVDashboardPage() {
|
||
const [rawData, setRawData] = useState({});
|
||
const [idx, setIdx] = useState(0);
|
||
const [k, setK] = useState(0);
|
||
const [paused, setPaused] = useState(false);
|
||
const scale = useStageScale(1920, 1080);
|
||
|
||
const loadData = useCallback(async () => {
|
||
const r = await apiFetch(`${API}/tv/stats`);
|
||
if (!r?.data) return;
|
||
const d = r.data;
|
||
const agents = d.agentList || [];
|
||
const noEncrypt = agents.filter(a => a.bitlocker_status && !['encrypted', 'on'].includes(String(a.bitlocker_status).toLowerCase())).length;
|
||
setRawData({
|
||
monitoring: { ...d.monitoring, noEncrypt },
|
||
agentList: agents,
|
||
tickets: d.tickets || {},
|
||
patch: d.patch || {},
|
||
lastSecReport: d.lastSecReport || null,
|
||
ticketMetrics: d.ticketMetrics || {},
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => { loadData(); const id = setInterval(loadData, 60000); return () => clearInterval(id); }, [loadData]);
|
||
|
||
const goTo = useCallback((i) => { setIdx(i); setK(x => x + 1); }, []);
|
||
const next = useCallback(() => goTo((idx + 1) % SLIDES.length), [idx, goTo]);
|
||
const prev = useCallback(() => goTo((idx - 1 + SLIDES.length) % SLIDES.length), [idx, goTo]);
|
||
|
||
useEffect(() => {
|
||
if (paused) return;
|
||
const t = setTimeout(next, SLIDE_DURATION);
|
||
return () => clearTimeout(t);
|
||
}, [idx, paused, next]);
|
||
|
||
useEffect(() => {
|
||
const h = (e) => {
|
||
if (e.key === 'ArrowRight' || e.key === ' ') { next(); e.preventDefault(); }
|
||
else if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }
|
||
else if (e.key === 'p' || e.key === 'P') setPaused(x => !x);
|
||
else if (e.key === 'f' || e.key === 'F') {
|
||
if (document.fullscreenElement) document.exitFullscreen?.();
|
||
else document.documentElement.requestFullscreen?.();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', h);
|
||
return () => window.removeEventListener('keydown', h);
|
||
}, [next, prev]);
|
||
|
||
const d = processData(rawData);
|
||
const { Component } = SLIDES[idx];
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', inset: 0, background: '#000', overflow: 'hidden', userSelect: 'none',
|
||
fontFamily: '"SF Pro Display","Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif',
|
||
WebkitFontSmoothing: 'antialiased' }}>
|
||
|
||
{/* ── 1920×1080 scaled stage ── */}
|
||
<div style={{
|
||
position: 'absolute', top: '50%', left: '50%',
|
||
width: 1920, height: 1080,
|
||
transform: `translate(-50%, -50%) scale(${scale})`,
|
||
transformOrigin: 'center center',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<Component d={d} k={k} />
|
||
<BrandChip section={SLIDES[idx].title} />
|
||
<ClockBig />
|
||
<SlideIndicator total={SLIDES.length} active={idx} durationMs={SLIDE_DURATION} autoplay={!paused} />
|
||
|
||
{/* Controls overlay (bottom) */}
|
||
<div style={{ position: 'absolute', bottom: 28, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: 12, zIndex: 10 }}>
|
||
<button onClick={prev} style={ctrlBtn}>‹</button>
|
||
<button onClick={() => setPaused(x => !x)} style={ctrlBtn}>{paused ? '▶' : '⏸'}</button>
|
||
<button onClick={next} style={ctrlBtn}>›</button>
|
||
<button onClick={() => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.(); }} style={{ ...ctrlBtn, marginLeft: 8 }}>⛶</button>
|
||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.2)', letterSpacing: 0.5, marginLeft: 4 }}>
|
||
{idx + 1}/{SLIDES.length} · ←/→ · P · F
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<style>{`
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||
|
||
/* ── Mesh blobs ── */
|
||
.tv-blob { position: absolute; border-radius: 50%; filter: blur(140px); will-change: transform; }
|
||
.tv-b1 { width: 1200px; height: 1200px; left: -200px; top: -300px; animation: tvBlob1 22s ease-in-out infinite alternate; }
|
||
.tv-b2 { width: 1000px; height: 1000px; right: -200px; top: -100px; animation: tvBlob2 28s ease-in-out infinite alternate; }
|
||
.tv-b3 { width: 900px; height: 900px; left: 20%; bottom: -300px; animation: tvBlob3 32s ease-in-out infinite alternate; }
|
||
.tv-b4 { width: 800px; height: 800px; right: 10%; bottom: -200px; animation: tvBlob4 26s ease-in-out infinite alternate; }
|
||
@keyframes tvBlob1 { from { transform: translate(0,0) scale(1); } to { transform: translate(180px,120px) scale(1.1); } }
|
||
@keyframes tvBlob2 { from { transform: translate(0,0) scale(1.05); } to { transform: translate(-120px,200px) scale(0.95); } }
|
||
@keyframes tvBlob3 { from { transform: translate(0,0) scale(0.95); } to { transform: translate(220px,-160px) scale(1.1); } }
|
||
@keyframes tvBlob4 { from { transform: translate(0,0) scale(1.1); } to { transform: translate(-180px,-100px) scale(0.9); } }
|
||
|
||
/* ── Brand dot pulse ── */
|
||
.tv-brand-dot {
|
||
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
|
||
box-shadow: 0 0 14px currentColor, 0 0 4px currentColor;
|
||
animation: tvBrandPulse 2.4s ease-in-out infinite;
|
||
}
|
||
@keyframes tvBrandPulse {
|
||
0%,100% { opacity:1; box-shadow:0 0 14px currentColor,0 0 4px currentColor; }
|
||
50% { opacity:0.55; box-shadow:0 0 8px currentColor,0 0 3px currentColor; }
|
||
}
|
||
|
||
/* ── Status dot pulse ── */
|
||
.tv-dot-pulse { animation: tvDotPulse 2.4s ease-in-out infinite; }
|
||
@keyframes tvDotPulse {
|
||
0%,100% { transform:scale(1); opacity:1; }
|
||
50% { transform:scale(1.25); opacity:0.7; }
|
||
}
|
||
|
||
/* ── Number punch ── */
|
||
.tv-number-punch { display:inline-block; animation: tvNumberPunch 480ms cubic-bezier(.2,.7,.2,1) 1400ms both; }
|
||
@keyframes tvNumberPunch {
|
||
0% { transform:scale(1); } 60% { transform:scale(1.025); } 100% { transform:scale(1); }
|
||
}
|
||
|
||
/* ── Slide entry ── */
|
||
@keyframes tvSlideUp {
|
||
from { transform: translateY(28px); }
|
||
to { transform: translateY(0); }
|
||
}
|
||
|
||
/* ── Tile entry ── */
|
||
@keyframes tvTileIn {
|
||
from { transform: translateY(24px) scale(0.985); }
|
||
to { transform: translateY(0) scale(1); }
|
||
}
|
||
|
||
/* ── Heat cell entry ── */
|
||
@keyframes tvHeatIn {
|
||
from { transform: scale(0.4); }
|
||
to { transform: scale(1); }
|
||
}
|
||
|
||
/* ── Slide indicator fill ── */
|
||
@keyframes tvSiFill {
|
||
from { width: 0%; }
|
||
to { width: 100%; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ctrlBtn = {
|
||
background: 'rgba(255,255,255,0.08)',
|
||
border: '1px solid rgba(255,255,255,0.14)',
|
||
color: 'rgba(255,255,255,0.55)',
|
||
borderRadius: 10, width: 36, height: 36,
|
||
cursor: 'pointer', fontSize: 16,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
transition: 'all 0.15s',
|
||
};
|