diff --git a/backend/src/controllers/tv.controller.js b/backend/src/controllers/tv.controller.js index 78f39fa..ebe15ee 100644 --- a/backend/src/controllers/tv.controller.js +++ b/backend/src/controllers/tv.controller.js @@ -1,4 +1,52 @@ const { getDatabase } = require('../config/database'); +const https = require('https'); + +// In-memory CVE cache — refresh every hour +let cveCache = { ts: 0, data: [] }; + +function fetchCVEs() { + return new Promise((resolve) => { + const opts = { + hostname: 'services.nvd.nist.gov', + path: '/rest/json/cves/2.0?resultsPerPage=8&sortBy=published&sortOrder=desc', + headers: { 'User-Agent': 'IT-Nexus-TV/1.0' }, + timeout: 8000, + }; + const req = https.get(opts, (res) => { + let raw = ''; + res.on('data', c => raw += c); + res.on('end', () => { + try { + const json = JSON.parse(raw); + const items = (json.vulnerabilities || []).map(v => { + const cve = v.cve; + const m31 = cve.metrics?.cvssMetricV31 || []; + const m30 = cve.metrics?.cvssMetricV30 || []; + const m = m31[0] || m30[0]; + const score = m?.cvssData?.baseScore || 0; + const sevRaw = (m?.cvssData?.baseSeverity || 'medium').toLowerCase(); + const sev = ['critical','high','medium','low'].includes(sevRaw) ? sevRaw : 'medium'; + const desc = cve.descriptions?.find(d => d.lang === 'en')?.value || ''; + const firstSentence = desc.split(/\.\s/)[0].replace(/\s+/g, ' ').trim(); + const title = firstSentence.length > 110 ? firstSentence.substring(0, 110) + '…' : firstSentence || cve.id; + const pub = new Date(cve.published); + const diffH = Math.round((Date.now() - pub.getTime()) / 3600000); + const published = diffH < 1 ? '< 1 Std.' : diffH < 24 ? `${diffH} Std.` : `${Math.round(diffH / 24)} Tag${Math.round(diffH / 24) !== 1 ? 'e' : ''}`; + // Vendor from CPE or references + let vendor = 'NVD'; + const cpe = cve.configurations?.[0]?.nodes?.[0]?.cpeMatch?.[0]?.criteria || ''; + const m2 = cpe.match(/cpe:2\.3:[ao]:([^:]+):/); + if (m2) vendor = m2[1].charAt(0).toUpperCase() + m2[1].slice(1).replace(/_/g,' '); + return { id: cve.id, sev, cvss: score, title, vendor, published }; + }).filter(c => c.cvss >= 5).slice(0, 5); + resolve(items.length >= 3 ? items : null); + } catch { resolve(null); } + }); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} async function getStats(req, res) { try { @@ -73,4 +121,20 @@ async function getStats(req, res) { } } -module.exports = { getStats }; +async function getCVEs(req, res) { + const now = Date.now(); + if (now - cveCache.ts < 3600000 && cveCache.data.length >= 3) { + return res.json({ status: 'success', data: cveCache.data, cached: true }); + } + const fresh = await fetchCVEs(); + if (fresh) { + cveCache = { ts: now, data: fresh }; + return res.json({ status: 'success', data: fresh, cached: false }); + } + if (cveCache.data.length >= 3) { + return res.json({ status: 'success', data: cveCache.data, cached: true }); + } + res.status(503).json({ status: 'error', message: 'CVE-Feed nicht erreichbar' }); +} + +module.exports = { getStats, getCVEs }; diff --git a/backend/src/routes/tv.routes.js b/backend/src/routes/tv.routes.js index 9093d82..7c9f775 100644 --- a/backend/src/routes/tv.routes.js +++ b/backend/src/routes/tv.routes.js @@ -1,7 +1,8 @@ const express = require('express'); const router = express.Router(); -const { getStats } = require('../controllers/tv.controller'); +const { getStats, getCVEs } = require('../controllers/tv.controller'); router.get('/stats', getStats); +router.get('/cves', getCVEs); module.exports = router; diff --git a/frontend/src/pages/TVDashboardPage.jsx b/frontend/src/pages/TVDashboardPage.jsx index 0d70353..7f36d0b 100644 --- a/frontend/src/pages/TVDashboardPage.jsx +++ b/frontend/src/pages/TVDashboardPage.jsx @@ -1,25 +1,13 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } 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'], -}; +/* ── Design tokens ──────────────────────────────────────────── */ +const LIME = '#7CF53E'; +const C_OK = '#34D399'; +const C_WARN = '#F59E0B'; +const C_ERROR = '#EF4444'; /* ════════════════════════════════════════════════════════════ HOOKS @@ -72,77 +60,9 @@ function useStageScale(w = 1920, h = 1080) { } /* ════════════════════════════════════════════════════════════ - ATOMS + ATOMS — shared ════════════════════════════════════════════════════════════ */ -function MeshBackground({ tint = 'neutral', intensity = 1 }) { - const p = MESH_PAL[tint] || MESH_PAL.neutral; - const k = intensity; - return ( -
-
-
-
-
-
-
")` }} /> -
-
- ); -} - -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 ( -
-
- {hh} - : - {mm} - : - {ss} -
-
{dateStr}
-
- ); -} - -function BrandChip({ section }) { - return ( -
- - IT NEXUS - {section && <> - - {section} - } -
- ); -} - -function SlideIndicator({ total, active, durationMs, autoplay }) { - return ( -
- {Array.from({ length: total }).map((_, i) => ( -
- {i === active && autoplay && ( -
- )} -
- ))} -
- ); -} - -function Sparkline({ data = [], width = 400, height = 80, color = ACCENT, fill = true, strokeW = 2 }) { +function Sparkline({ data = [], width = 400, height = 80, color = LIME, 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; @@ -166,13 +86,13 @@ function Sparkline({ data = [], width = 400, height = 80, color = ACCENT, fill = ); } -function Donut({ value, size = 220, stroke = 16, color = ACCENT, label, sublabel, animateKey }) { +function Donut({ value, size = 220, stroke = 16, color = LIME, 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 ( -
+
-
-
- {Math.round(animated)}% -
- {label &&
{label}
} - {sublabel &&
{sublabel}
} +
+
{Math.round(animated)}%
+ {label &&
{label}
} + {sublabel &&
{sublabel}
}
); @@ -196,114 +114,188 @@ function BigNumber({ value, suffix = '', decimals = 0, animateKey }) { const display = decimals === 0 ? Math.round(animated).toLocaleString('de-DE') : animated.toFixed(decimals).replace('.', ','); - return {display}{suffix}; + return {display}{suffix}; } 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 colors = { ok: C_OK, online: C_OK, warn: C_WARN, error: C_ERROR, offline: C_ERROR }; const c = colors[status] || '#94A3B8'; const pulse = status === 'ok' || status === 'online'; return ( - + ); } -/* ── 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 }) { +/* ════════════════════════════════════════════════════════════ + WM ATOMS — football theme +════════════════════════════════════════════════════════════ */ +function PitchBackground() { + const L = 'rgba(255,255,255,0.85)'; return ( -
- {/* hairline */} -
- {/* glow */} - {TILE_GLOWS[variant] && ( -
- )} - {cornerGlyph && ( -
- {cornerGlyph} -
- )} - {children} +