Files
IT-Nexus/frontend/src/pages/TVDashboardPage.jsx

1028 lines
62 KiB
React
Raw Normal View History

2026-06-01 20:49:07 +02:00
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',
};