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

1028 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
};