[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)

Phase 1 — Python (klausur-service): 5 monoliths → 36 files
- dsfa_corpus_ingestion.py (1,828 LOC → 5 files)
- cv_ocr_engines.py (2,102 LOC → 7 files)
- cv_layout.py (3,653 LOC → 10 files)
- vocab_worksheet_api.py (2,783 LOC → 8 files)
- grid_build_core.py (1,958 LOC → 6 files)

Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files
- staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3)
- policy_handlers.go (700 → 2), repository.go (684 → 2)
- search.go (592 → 2), ai_extraction_handlers.go (554 → 2)
- seed_data.go (591 → 2), grade_service.go (646 → 2)

Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files
- sdk/types.ts (2,108 → 16 domain files)
- ai/rag/page.tsx (2,686 → 14 files)
- 22 page.tsx files split into _components/ + _hooks/
- 11 component files split into sub-components
- 10 SDK data catalogs added to loc-exceptions
- Deleted dead backup index_original.ts (4,899 LOC)

All original public APIs preserved via re-export facades.
Zero new errors: Python imports verified, Go builds clean,
TypeScript tsc --noEmit shows only pre-existing errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:28:57 +02:00
parent 9ba420fa91
commit b681ddb131
251 changed files with 30016 additions and 25037 deletions

View File

@@ -0,0 +1,63 @@
'use client'
import type { MiddlewareConfig } from '../types'
import { getMiddlewareDescription } from './helpers'
interface ConfigTabProps {
configs: MiddlewareConfig[]
actionLoading: string | null
toggleMiddleware: (name: string, enabled: boolean) => void
}
export function ConfigTab({ configs, actionLoading, toggleMiddleware }: ConfigTabProps) {
return (
<div className="space-y-4">
{configs.map(config => {
const info = getMiddlewareDescription(config.middleware_name)
return (
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<span>{info.icon}</span>
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
</h3>
<p className="text-sm text-slate-600">{info.desc}</p>
</div>
<div className="flex items-center gap-3">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
</span>
<button
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
disabled={actionLoading === config.middleware_name}
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
>
{actionLoading === config.middleware_name
? '...'
: config.enabled
? 'Deaktivieren'
: 'Aktivieren'}
</button>
</div>
</div>
{Object.keys(config.config).length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-200">
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
Konfiguration
</div>
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
{JSON.stringify(config.config, null, 2)}
</pre>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import type { MiddlewareEvent } from '../types'
import { getEventTypeColor } from './helpers'
interface EventsTabProps {
events: MiddlewareEvent[]
}
export function EventsTab({ events }: EventsTabProps) {
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Zeit
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Middleware
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Event
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
IP
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Pfad
</th>
</tr>
</thead>
<tbody>
{events.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
Keine Events vorhanden.
</td>
</tr>
) : (
events.map(event => (
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(event.created_at).toLocaleString('de-DE')}
</td>
<td className="py-3 px-4 text-sm capitalize">
{event.middleware_name.replace('_', ' ')}
</td>
<td className="py-3 px-4">
<span
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
>
{event.event_type}
</span>
</td>
<td className="py-3 px-4 text-sm font-mono text-slate-600">
{event.ip_address || '-'}
</td>
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
{event.request_method && event.request_path
? `${event.request_method} ${event.request_path}`
: '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import type { RateLimitIP } from '../types'
interface IpListTabProps {
ipList: RateLimitIP[]
actionLoading: string | null
newIP: string
setNewIP: (v: string) => void
newIPType: 'whitelist' | 'blacklist'
setNewIPType: (v: 'whitelist' | 'blacklist') => void
newIPReason: string
setNewIPReason: (v: string) => void
addIP: (e: React.FormEvent) => void
removeIP: (id: string) => void
}
export function IpListTab({
ipList,
actionLoading,
newIP,
setNewIP,
newIPType,
setNewIPType,
newIPReason,
setNewIPReason,
addIP,
removeIP,
}: IpListTabProps) {
return (
<div>
{/* Add IP Form */}
<form onSubmit={addIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
<div className="flex flex-wrap gap-3">
<input
type="text"
value={newIP}
onChange={e => setNewIP(e.target.value)}
placeholder="IP-Adresse (z.B. 192.168.1.1)"
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<select
value={newIPType}
onChange={e => setNewIPType(e.target.value as 'whitelist' | 'blacklist')}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
>
<option value="whitelist">Whitelist</option>
<option value="blacklist">Blacklist</option>
</select>
<input
type="text"
value={newIPReason}
onChange={e => setNewIPReason(e.target.value)}
placeholder="Grund (optional)"
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button
type="submit"
disabled={!newIP.trim() || actionLoading === 'add-ip'}
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
</button>
</div>
</form>
{/* IP List Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
IP-Adresse
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Typ
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Grund
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Hinzugefuegt
</th>
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Aktion
</th>
</tr>
</thead>
<tbody>
{ipList.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
Keine IP-Eintraege vorhanden.
</td>
</tr>
) : (
ipList.map(ip => (
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
<td className="py-3 px-4">
<span
className={`px-2 py-1 rounded text-xs font-semibold ${
ip.list_type === 'whitelist'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
</span>
</td>
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(ip.created_at).toLocaleString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => removeIP(ip.id)}
disabled={actionLoading === `remove-${ip.id}`}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
>
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import type { MiddlewareConfig } from '../types'
import { getMiddlewareDescription } from './helpers'
interface OverviewTabProps {
configs: MiddlewareConfig[]
actionLoading: string | null
toggleMiddleware: (name: string, enabled: boolean) => void
}
export function OverviewTab({ configs, actionLoading, toggleMiddleware }: OverviewTabProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{configs.map(config => {
const info = getMiddlewareDescription(config.middleware_name)
return (
<div
key={config.id}
className={`rounded-lg p-4 border ${
config.enabled
? 'bg-green-50 border-green-200'
: 'bg-slate-50 border-slate-200'
}`}
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{info.icon}</span>
<span className="font-semibold text-slate-900 capitalize">
{config.middleware_name.replace('_', ' ')}
</span>
</div>
<button
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
disabled={actionLoading === config.middleware_name}
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
config.enabled
? 'bg-green-200 text-green-800 hover:bg-green-300'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
}`}
>
{actionLoading === config.middleware_name
? '...'
: config.enabled
? 'Aktiv'
: 'Inaktiv'}
</button>
</div>
<p className="text-sm text-slate-600">{info.desc}</p>
{config.updated_at && (
<div className="mt-2 text-xs text-slate-400">
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import type { MiddlewareStats } from '../types'
import { getMiddlewareDescription, getEventTypeColor } from './helpers'
interface StatsTabProps {
stats: MiddlewareStats[]
}
export function StatsTab({ stats }: StatsTabProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{stats.map(stat => {
const info = getMiddlewareDescription(stat.middleware_name)
return (
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
<span>{info.icon}</span>
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
</h3>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
<div className="text-xs text-slate-500">Gesamt</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
<div className="text-xs text-slate-500">Letzte Stunde</div>
</div>
<div>
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
<div className="text-xs text-slate-500">24 Stunden</div>
</div>
</div>
{stat.top_event_types.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
Top Event-Typen
</div>
<div className="flex flex-wrap gap-2">
{stat.top_event_types.slice(0, 3).map(et => (
<span
key={et.event_type}
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
>
{et.event_type} ({et.count})
</span>
))}
</div>
</div>
)}
{stat.top_ips.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
<div className="text-xs text-slate-600">
{stat.top_ips
.slice(0, 3)
.map(ip => `${ip.ip_address} (${ip.count})`)
.join(', ')}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,24 @@
export function getMiddlewareDescription(name: string): { icon: string; desc: string } {
const descriptions: Record<string, { icon: string; desc: string }> = {
request_id: { icon: '\u{1F194}', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
security_headers: { icon: '\u{1F6E1}\uFE0F', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
cors: { icon: '\u{1F310}', desc: 'Cross-Origin Resource Sharing Konfiguration' },
rate_limiter: { icon: '\u23F1\uFE0F', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
pii_redactor: { icon: '\u{1F512}', desc: 'Redaktiert personenbezogene Daten in Logs' },
input_gate: { icon: '\u{1F6AA}', desc: 'Validiert und sanitisiert Eingaben' },
}
return descriptions[name] || { icon: '\u2699\uFE0F', desc: 'Middleware-Komponente' }
}
export function getEventTypeColor(eventType: string): string {
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
return 'bg-red-100 text-red-800'
}
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
return 'bg-yellow-100 text-yellow-800'
}
if (eventType.includes('success') || eventType.includes('whitelist')) {
return 'bg-green-100 text-green-800'
}
return 'bg-slate-100 text-slate-800'
}

View File

@@ -7,210 +7,25 @@
* Migrated from old admin (/admin/middleware)
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { useMiddlewareAdmin } from './useMiddlewareAdmin'
import { OverviewTab } from './_components/OverviewTab'
import { ConfigTab } from './_components/ConfigTab'
import { IpListTab } from './_components/IpListTab'
import { EventsTab } from './_components/EventsTab'
import { StatsTab } from './_components/StatsTab'
import type { TabId } from './types'
interface MiddlewareConfig {
id: string
middleware_name: string
enabled: boolean
config: Record<string, unknown>
updated_at: string | null
}
interface RateLimitIP {
id: string
ip_address: string
list_type: 'whitelist' | 'blacklist'
reason: string | null
expires_at: string | null
created_at: string
}
interface MiddlewareEvent {
id: string
middleware_name: string
event_type: string
ip_address: string | null
user_id: string | null
request_path: string | null
request_method: string | null
details: Record<string, unknown> | null
created_at: string
}
interface MiddlewareStats {
middleware_name: string
total_events: number
events_last_hour: number
events_last_24h: number
top_event_types: Array<{ event_type: string; count: number }>
top_ips: Array<{ ip_address: string; count: number }>
const TAB_LABELS: Record<TabId, string> = {
overview: 'Uebersicht',
config: 'Konfiguration',
'ip-list': 'IP-Listen',
events: 'Events',
stats: 'Statistiken',
}
export default function MiddlewareAdminPage() {
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
const [ipList, setIpList] = useState<RateLimitIP[]>([])
const [events, setEvents] = useState<MiddlewareEvent[]>([])
const [stats, setStats] = useState<MiddlewareStats[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'overview' | 'config' | 'ip-list' | 'events' | 'stats'>('overview')
const [actionLoading, setActionLoading] = useState<string | null>(null)
// IP Form
const [newIP, setNewIP] = useState('')
const [newIPType, setNewIPType] = useState<'whitelist' | 'blacklist'>('whitelist')
const [newIPReason, setNewIPReason] = useState('')
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [configsRes, ipListRes, eventsRes, statsRes] = await Promise.all([
fetch('/api/admin/middleware'),
fetch('/api/admin/middleware/rate-limit/ip-list'),
fetch('/api/admin/middleware/events?limit=50'),
fetch('/api/admin/middleware/stats'),
])
if (configsRes.ok) {
setConfigs(await configsRes.json())
}
if (ipListRes.ok) {
setIpList(await ipListRes.json())
}
if (eventsRes.ok) {
setEvents(await eventsRes.json())
}
if (statsRes.ok) {
setStats(await statsRes.json())
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const toggleMiddleware = async (name: string, enabled: boolean) => {
setActionLoading(name)
setError(null)
try {
const response = await fetch(`/api/admin/middleware/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
throw new Error(`Fehler beim Aktualisieren: ${response.statusText}`)
}
// Update local state
setConfigs(prev =>
prev.map(c => (c.middleware_name === name ? { ...c, enabled } : c))
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Aktualisierung fehlgeschlagen')
} finally {
setActionLoading(null)
}
}
const addIP = async (e: React.FormEvent) => {
e.preventDefault()
if (!newIP.trim()) return
setActionLoading('add-ip')
setError(null)
try {
const response = await fetch('/api/admin/middleware/rate-limit/ip-list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip_address: newIP.trim(),
list_type: newIPType,
reason: newIPReason.trim() || null,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || `Fehler: ${response.statusText}`)
}
const newEntry = await response.json()
setIpList(prev => [newEntry, ...prev])
setNewIP('')
setNewIPReason('')
} catch (err) {
setError(err instanceof Error ? err.message : 'IP konnte nicht hinzugefuegt werden')
} finally {
setActionLoading(null)
}
}
const removeIP = async (id: string) => {
setActionLoading(`remove-${id}`)
setError(null)
try {
const response = await fetch(`/api/admin/middleware/rate-limit/ip-list/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error(`Fehler beim Loeschen: ${response.statusText}`)
}
setIpList(prev => prev.filter(ip => ip.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : 'IP konnte nicht entfernt werden')
} finally {
setActionLoading(null)
}
}
const getMiddlewareDescription = (name: string): { icon: string; desc: string } => {
const descriptions: Record<string, { icon: string; desc: string }> = {
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
}
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
}
const getEventTypeColor = (eventType: string) => {
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
return 'bg-red-100 text-red-800'
}
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
return 'bg-yellow-100 text-yellow-800'
}
if (eventType.includes('success') || eventType.includes('whitelist')) {
return 'bg-green-100 text-green-800'
}
return 'bg-slate-100 text-slate-800'
}
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
const mw = useMiddlewareAdmin()
return (
<div>
@@ -237,29 +52,29 @@ export default function MiddlewareAdminPage() {
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
<button
onClick={fetchData}
disabled={loading}
onClick={mw.fetchData}
disabled={mw.loading}
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Laden...' : 'Aktualisieren'}
{mw.loading ? 'Laden...' : 'Aktualisieren'}
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="text-2xl font-bold text-slate-900">{configs.length}</div>
<div className="text-2xl font-bold text-slate-900">{mw.configs.length}</div>
<div className="text-sm text-slate-600">Middleware</div>
</div>
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
<div className="text-2xl font-bold text-green-600">{mw.whitelistCount}</div>
<div className="text-sm text-slate-600">Whitelist IPs</div>
</div>
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
<div className="text-2xl font-bold text-red-600">{mw.blacklistCount}</div>
<div className="text-sm text-slate-600">Blacklist IPs</div>
</div>
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
<div className="text-2xl font-bold text-blue-600">{events.length}</div>
<div className="text-2xl font-bold text-blue-600">{mw.events.length}</div>
<div className="text-sm text-slate-600">Recent Events</div>
</div>
</div>
@@ -271,359 +86,59 @@ export default function MiddlewareAdminPage() {
{(['overview', 'config', 'ip-list', 'events', 'stats'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
onClick={() => mw.setActiveTab(tab)}
className={`px-6 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === tab
mw.activeTab === tab
? 'bg-orange-50 text-orange-700 border-b-2 border-orange-600'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
{tab === 'overview' && 'Uebersicht'}
{tab === 'config' && 'Konfiguration'}
{tab === 'ip-list' && `IP-Listen (${ipList.length})`}
{tab === 'events' && 'Events'}
{tab === 'stats' && 'Statistiken'}
{tab === 'ip-list' ? `${TAB_LABELS[tab]} (${mw.ipList.length})` : TAB_LABELS[tab]}
</button>
))}
</div>
<div className="p-6">
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">{error}</div>
{mw.error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">{mw.error}</div>
)}
{loading ? (
{mw.loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-600" />
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{configs.map(config => {
const info = getMiddlewareDescription(config.middleware_name)
return (
<div
key={config.id}
className={`rounded-lg p-4 border ${
config.enabled
? 'bg-green-50 border-green-200'
: 'bg-slate-50 border-slate-200'
}`}
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{info.icon}</span>
<span className="font-semibold text-slate-900 capitalize">
{config.middleware_name.replace('_', ' ')}
</span>
</div>
<button
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
disabled={actionLoading === config.middleware_name}
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
config.enabled
? 'bg-green-200 text-green-800 hover:bg-green-300'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
}`}
>
{actionLoading === config.middleware_name
? '...'
: config.enabled
? 'Aktiv'
: 'Inaktiv'}
</button>
</div>
<p className="text-sm text-slate-600">{info.desc}</p>
{config.updated_at && (
<div className="mt-2 text-xs text-slate-400">
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
</div>
)}
</div>
)
})}
</div>
{mw.activeTab === 'overview' && (
<OverviewTab
configs={mw.configs}
actionLoading={mw.actionLoading}
toggleMiddleware={mw.toggleMiddleware}
/>
)}
{/* Config Tab */}
{activeTab === 'config' && (
<div className="space-y-4">
{configs.map(config => {
const info = getMiddlewareDescription(config.middleware_name)
return (
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<span>{info.icon}</span>
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
</h3>
<p className="text-sm text-slate-600">{info.desc}</p>
</div>
<div className="flex items-center gap-3">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
</span>
<button
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
disabled={actionLoading === config.middleware_name}
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
>
{actionLoading === config.middleware_name
? '...'
: config.enabled
? 'Deaktivieren'
: 'Aktivieren'}
</button>
</div>
</div>
{Object.keys(config.config).length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-200">
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
Konfiguration
</div>
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
{JSON.stringify(config.config, null, 2)}
</pre>
</div>
)}
</div>
)
})}
</div>
{mw.activeTab === 'config' && (
<ConfigTab
configs={mw.configs}
actionLoading={mw.actionLoading}
toggleMiddleware={mw.toggleMiddleware}
/>
)}
{/* IP List Tab */}
{activeTab === 'ip-list' && (
<div>
{/* Add IP Form */}
<form onSubmit={addIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
<div className="flex flex-wrap gap-3">
<input
type="text"
value={newIP}
onChange={e => setNewIP(e.target.value)}
placeholder="IP-Adresse (z.B. 192.168.1.1)"
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<select
value={newIPType}
onChange={e => setNewIPType(e.target.value as 'whitelist' | 'blacklist')}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
>
<option value="whitelist">Whitelist</option>
<option value="blacklist">Blacklist</option>
</select>
<input
type="text"
value={newIPReason}
onChange={e => setNewIPReason(e.target.value)}
placeholder="Grund (optional)"
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button
type="submit"
disabled={!newIP.trim() || actionLoading === 'add-ip'}
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
</button>
</div>
</form>
{/* IP List Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
IP-Adresse
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Typ
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Grund
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Hinzugefuegt
</th>
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Aktion
</th>
</tr>
</thead>
<tbody>
{ipList.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
Keine IP-Eintraege vorhanden.
</td>
</tr>
) : (
ipList.map(ip => (
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
<td className="py-3 px-4">
<span
className={`px-2 py-1 rounded text-xs font-semibold ${
ip.list_type === 'whitelist'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
</span>
</td>
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(ip.created_at).toLocaleString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => removeIP(ip.id)}
disabled={actionLoading === `remove-${ip.id}`}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
>
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* Events Tab */}
{activeTab === 'events' && (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Zeit
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Middleware
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Event
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
IP
</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
Pfad
</th>
</tr>
</thead>
<tbody>
{events.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
Keine Events vorhanden.
</td>
</tr>
) : (
events.map(event => (
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(event.created_at).toLocaleString('de-DE')}
</td>
<td className="py-3 px-4 text-sm capitalize">
{event.middleware_name.replace('_', ' ')}
</td>
<td className="py-3 px-4">
<span
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
>
{event.event_type}
</span>
</td>
<td className="py-3 px-4 text-sm font-mono text-slate-600">
{event.ip_address || '-'}
</td>
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
{event.request_method && event.request_path
? `${event.request_method} ${event.request_path}`
: '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{stats.map(stat => {
const info = getMiddlewareDescription(stat.middleware_name)
return (
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
<span>{info.icon}</span>
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
</h3>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
<div className="text-xs text-slate-500">Gesamt</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
<div className="text-xs text-slate-500">Letzte Stunde</div>
</div>
<div>
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
<div className="text-xs text-slate-500">24 Stunden</div>
</div>
</div>
{stat.top_event_types.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
Top Event-Typen
</div>
<div className="flex flex-wrap gap-2">
{stat.top_event_types.slice(0, 3).map(et => (
<span
key={et.event_type}
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
>
{et.event_type} ({et.count})
</span>
))}
</div>
</div>
)}
{stat.top_ips.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
<div className="text-xs text-slate-600">
{stat.top_ips
.slice(0, 3)
.map(ip => `${ip.ip_address} (${ip.count})`)
.join(', ')}
</div>
</div>
)}
</div>
)
})}
</div>
{mw.activeTab === 'ip-list' && (
<IpListTab
ipList={mw.ipList}
actionLoading={mw.actionLoading}
newIP={mw.newIP}
setNewIP={mw.setNewIP}
newIPType={mw.newIPType}
setNewIPType={mw.setNewIPType}
newIPReason={mw.newIPReason}
setNewIPReason={mw.setNewIPReason}
addIP={mw.addIP}
removeIP={mw.removeIP}
/>
)}
{mw.activeTab === 'events' && <EventsTab events={mw.events} />}
{mw.activeTab === 'stats' && <StatsTab stats={mw.stats} />}
</>
)}
</div>

View File

@@ -0,0 +1,39 @@
export interface MiddlewareConfig {
id: string
middleware_name: string
enabled: boolean
config: Record<string, unknown>
updated_at: string | null
}
export interface RateLimitIP {
id: string
ip_address: string
list_type: 'whitelist' | 'blacklist'
reason: string | null
expires_at: string | null
created_at: string
}
export interface MiddlewareEvent {
id: string
middleware_name: string
event_type: string
ip_address: string | null
user_id: string | null
request_path: string | null
request_method: string | null
details: Record<string, unknown> | null
created_at: string
}
export interface MiddlewareStats {
middleware_name: string
total_events: number
events_last_hour: number
events_last_24h: number
top_event_types: Array<{ event_type: string; count: number }>
top_ips: Array<{ ip_address: string; count: number }>
}
export type TabId = 'overview' | 'config' | 'ip-list' | 'events' | 'stats'

View File

@@ -0,0 +1,167 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import type { MiddlewareConfig, RateLimitIP, MiddlewareEvent, MiddlewareStats, TabId } from './types'
export function useMiddlewareAdmin() {
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
const [ipList, setIpList] = useState<RateLimitIP[]>([])
const [events, setEvents] = useState<MiddlewareEvent[]>([])
const [stats, setStats] = useState<MiddlewareStats[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [actionLoading, setActionLoading] = useState<string | null>(null)
// IP Form
const [newIP, setNewIP] = useState('')
const [newIPType, setNewIPType] = useState<'whitelist' | 'blacklist'>('whitelist')
const [newIPReason, setNewIPReason] = useState('')
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [configsRes, ipListRes, eventsRes, statsRes] = await Promise.all([
fetch('/api/admin/middleware'),
fetch('/api/admin/middleware/rate-limit/ip-list'),
fetch('/api/admin/middleware/events?limit=50'),
fetch('/api/admin/middleware/stats'),
])
if (configsRes.ok) {
setConfigs(await configsRes.json())
}
if (ipListRes.ok) {
setIpList(await ipListRes.json())
}
if (eventsRes.ok) {
setEvents(await eventsRes.json())
}
if (statsRes.ok) {
setStats(await statsRes.json())
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const toggleMiddleware = async (name: string, enabled: boolean) => {
setActionLoading(name)
setError(null)
try {
const response = await fetch(`/api/admin/middleware/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
throw new Error(`Fehler beim Aktualisieren: ${response.statusText}`)
}
setConfigs(prev =>
prev.map(c => (c.middleware_name === name ? { ...c, enabled } : c))
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Aktualisierung fehlgeschlagen')
} finally {
setActionLoading(null)
}
}
const addIP = async (e: React.FormEvent) => {
e.preventDefault()
if (!newIP.trim()) return
setActionLoading('add-ip')
setError(null)
try {
const response = await fetch('/api/admin/middleware/rate-limit/ip-list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip_address: newIP.trim(),
list_type: newIPType,
reason: newIPReason.trim() || null,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || `Fehler: ${response.statusText}`)
}
const newEntry = await response.json()
setIpList(prev => [newEntry, ...prev])
setNewIP('')
setNewIPReason('')
} catch (err) {
setError(err instanceof Error ? err.message : 'IP konnte nicht hinzugefuegt werden')
} finally {
setActionLoading(null)
}
}
const removeIP = async (id: string) => {
setActionLoading(`remove-${id}`)
setError(null)
try {
const response = await fetch(`/api/admin/middleware/rate-limit/ip-list/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error(`Fehler beim Loeschen: ${response.statusText}`)
}
setIpList(prev => prev.filter(ip => ip.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : 'IP konnte nicht entfernt werden')
} finally {
setActionLoading(null)
}
}
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
return {
configs,
ipList,
events,
stats,
loading,
error,
activeTab,
setActiveTab,
actionLoading,
newIP,
setNewIP,
newIPType,
setNewIPType,
newIPReason,
setNewIPReason,
fetchData,
toggleMiddleware,
addIP,
removeIP,
whitelistCount,
blacklistCount,
}
}

View File

@@ -0,0 +1,20 @@
export function InfoBox() {
return (
<div className="mt-6 bg-orange-50 border border-orange-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-orange-900">Hinweise zur Nachtabschaltung</h4>
<ul className="text-sm text-orange-800 mt-1 space-y-1 list-disc list-inside">
<li>Der <strong>night-scheduler</strong> und <strong>nginx</strong> bleiben immer aktiv</li>
<li>Services werden mit <code>docker compose stop</code> angehalten (Daten bleiben erhalten)</li>
<li>Bei manuellem Start/Stop wird die letzte Aktion gespeichert</li>
<li>Der Scheduler prueft jede Minute, ob eine Aktion faellig ist</li>
</ul>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import type { NightModeConfig } from './types'
interface MainControlProps {
editConfig: NightModeConfig | null
actionLoading: string | null
onToggle: () => void
onExecute: (action: 'start' | 'stop') => void
}
export function MainControl({ editConfig, actionLoading, onToggle, onExecute }: MainControlProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
{/* Toggle */}
<div className="flex items-center gap-6">
<div
onClick={onToggle}
className={`relative inline-flex h-10 w-20 cursor-pointer items-center rounded-full transition-colors ${
editConfig?.enabled ? 'bg-orange-600' : 'bg-slate-300'
} ${actionLoading === 'toggle' ? 'opacity-50 cursor-wait' : ''}`}
>
<span
className={`inline-block h-8 w-8 transform rounded-full bg-white shadow-lg transition-transform ${
editConfig?.enabled ? 'translate-x-11' : 'translate-x-1'
}`}
/>
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">
Nachtmodus: {editConfig?.enabled ? 'Aktiv' : 'Inaktiv'}
</h2>
<p className="text-sm text-slate-500">
{editConfig?.enabled
? `Abschaltung um ${editConfig.shutdown_time}, Start um ${editConfig.startup_time}`
: 'Zeitgesteuerte Abschaltung ist deaktiviert'}
</p>
</div>
</div>
{/* Manuelle Aktionen */}
<div className="flex gap-3">
<button
onClick={() => onExecute('stop')}
disabled={actionLoading !== null}
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'stop' ? (
<span className="animate-spin">&#9696;</span>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
)}
Jetzt abschalten
</button>
<button
onClick={() => onExecute('start')}
disabled={actionLoading !== null}
className="flex items-center gap-2 px-5 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'start' ? (
<span className="animate-spin">&#9696;</span>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
Jetzt starten
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import type { NightModeStatus, ServicesInfo } from './types'
interface ServiceListProps {
services: ServicesInfo | null
status: NightModeStatus | null
}
function getServiceStatusColor(state: string) {
const lower = state.toLowerCase()
if (lower === 'running' || lower.includes('up')) {
return 'bg-green-100 text-green-800'
}
if (lower === 'exited' || lower.includes('exit')) {
return 'bg-slate-100 text-slate-600'
}
if (lower === 'paused' || lower.includes('pause')) {
return 'bg-yellow-100 text-yellow-800'
}
return 'bg-slate-100 text-slate-600'
}
export function ServiceList({ services, status }: ServiceListProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Services</h3>
<p className="text-sm text-slate-500 mt-1">
Gruen = wird verwaltet, Grau = ausgeschlossen (laeuft immer)
</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{services?.all_services.map(service => {
const serviceStatus = status?.services_status[service] || 'unknown'
const isExcluded = services.excluded_services.includes(service)
return (
<div
key={service}
className={`flex items-center justify-between px-4 py-3 rounded-lg border ${
isExcluded
? 'bg-slate-50 border-slate-200'
: 'bg-orange-50 border-orange-200'
}`}
>
<div className="flex items-center gap-2">
{isExcluded ? (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span className={`text-sm font-medium ${isExcluded ? 'text-slate-500' : 'text-slate-900'}`}>
{service}
</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${getServiceStatusColor(serviceStatus)}`}>
{serviceStatus}
</span>
</div>
)
})}
{/* Auch excluded Services anzeigen, die nicht in all_services sind */}
{services?.excluded_services
.filter(s => !services.all_services.includes(s))
.map(service => (
<div
key={service}
className="flex items-center justify-between px-4 py-3 rounded-lg border bg-slate-50 border-slate-200"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-sm font-medium text-slate-500">
{service}
</span>
</div>
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-slate-100 text-slate-600">
excluded
</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import type { NightModeStatus } from './types'
interface StatusCardsProps {
status: NightModeStatus | null
runningCount: number
stoppedCount: number
}
export function StatusCards({ status, runningCount, stoppedCount }: StatusCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{/* Current Time */}
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{status?.current_time || '--:--'}</div>
<div className="text-sm text-slate-500">Aktuelle Zeit</div>
</div>
</div>
</div>
{/* Next Action */}
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
status?.next_action === 'shutdown' ? 'bg-red-100' : 'bg-green-100'
}`}>
{status?.next_action === 'shutdown' ? (
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</div>
<div>
<div className="text-2xl font-bold text-slate-900">
{status?.next_action_time || '--:--'}
</div>
<div className="text-sm text-slate-500">
{status?.next_action === 'shutdown' ? 'Naechste Abschaltung' : 'Naechster Start'}
</div>
</div>
</div>
</div>
{/* Countdown */}
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-orange-100 flex items-center justify-center">
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">
{status?.time_until_next_action || '-'}
</div>
<div className="text-sm text-slate-500">Countdown</div>
</div>
</div>
</div>
{/* Running / Stopped */}
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center">
<svg className="w-6 h-6 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-green-600">{runningCount}</span>
<span className="text-slate-400">/</span>
<span className="text-lg font-bold text-slate-600">{stoppedCount}</span>
</div>
<div className="text-sm text-slate-500">Aktiv / Gestoppt</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import type { NightModeConfig, NightModeStatus } from './types'
interface TimeConfigProps {
editMode: boolean
editConfig: NightModeConfig | null
actionLoading: string | null
status: NightModeStatus | null
onSetEditMode: (v: boolean) => void
onSetEditConfig: (fn: (prev: NightModeConfig | null) => NightModeConfig | null) => void
onSave: () => void
onCancel: () => void
}
export function TimeConfig({
editMode, editConfig, actionLoading, status,
onSetEditMode, onSetEditConfig, onSave, onCancel,
}: TimeConfigProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-semibold text-slate-900">Zeitkonfiguration</h3>
{editMode ? (
<div className="flex gap-2">
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
>
Abbrechen
</button>
<button
onClick={onSave}
disabled={actionLoading === 'save'}
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'save' ? 'Speichern...' : 'Speichern'}
</button>
</div>
) : (
<button
onClick={() => onSetEditMode(true)}
className="px-4 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
>
Bearbeiten
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
Abschaltung um
</span>
</label>
{editMode ? (
<input
type="time"
value={editConfig?.shutdown_time || '22:00'}
onChange={e => onSetEditConfig(prev => prev ? { ...prev, shutdown_time: e.target.value } : null)}
className="w-full px-4 py-3 text-xl font-mono border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
) : (
<div className="text-3xl font-mono font-bold text-slate-900 bg-slate-50 px-4 py-3 rounded-lg">
{editConfig?.shutdown_time || '22:00'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="flex items-center gap-2">
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Start um
</span>
</label>
{editMode ? (
<input
type="time"
value={editConfig?.startup_time || '06:00'}
onChange={e => onSetEditConfig(prev => prev ? { ...prev, startup_time: e.target.value } : null)}
className="w-full px-4 py-3 text-xl font-mono border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
) : (
<div className="text-3xl font-mono font-bold text-slate-900 bg-slate-50 px-4 py-3 rounded-lg">
{editConfig?.startup_time || '06:00'}
</div>
)}
</div>
</div>
{/* Letzte Aktion */}
{status?.config.last_action && (
<div className="mt-6 pt-6 border-t border-slate-200">
<div className="text-sm text-slate-500">
Letzte Aktion:{' '}
<span className={`font-semibold ${status.config.last_action === 'startup' ? 'text-green-600' : 'text-red-600'}`}>
{status.config.last_action === 'startup' ? 'Gestartet' : 'Abgeschaltet'}
</span>
{status.config.last_action_time && (
<span className="ml-2">
am {new Date(status.config.last_action_time).toLocaleString('de-DE')}
</span>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
export interface NightModeConfig {
enabled: boolean
shutdown_time: string
startup_time: string
last_action: string | null
last_action_time: string | null
excluded_services: string[]
}
export interface NightModeStatus {
config: NightModeConfig
current_time: string
next_action: string | null
next_action_time: string | null
time_until_next_action: string | null
services_status: Record<string, string>
}
export interface ServicesInfo {
all_services: string[]
excluded_services: string[]
status: Record<string, string>
}

View File

@@ -0,0 +1,174 @@
import { useEffect, useState, useCallback } from 'react'
import type { NightModeConfig, NightModeStatus, ServicesInfo } from './types'
export function useNightMode() {
const [status, setStatus] = useState<NightModeStatus | null>(null)
const [services, setServices] = useState<ServicesInfo | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [editMode, setEditMode] = useState(false)
const [editConfig, setEditConfig] = useState<NightModeConfig | null>(null)
const fetchData = useCallback(async () => {
setError(null)
try {
const [statusRes, servicesRes] = await Promise.all([
fetch('/api/admin/night-mode'),
fetch('/api/admin/night-mode/services'),
])
if (statusRes.ok) {
const data = await statusRes.json()
setStatus(data)
if (!editMode) {
setEditConfig(data.config)
}
} else {
const errData = await statusRes.json()
setError(errData.error || 'Fehler beim Laden des Status')
}
if (servicesRes.ok) {
setServices(await servicesRes.json())
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindung zum Night-Scheduler fehlgeschlagen')
} finally {
setLoading(false)
}
}, [editMode])
useEffect(() => {
fetchData()
}, [fetchData])
// Auto-Refresh alle 30 Sekunden
useEffect(() => {
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const saveConfig = async () => {
if (!editConfig) return
setActionLoading('save')
setError(null)
setSuccessMessage(null)
try {
const response = await fetch('/api/admin/night-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editConfig),
})
if (!response.ok) {
const errData = await response.json()
throw new Error(errData.error || 'Fehler beim Speichern')
}
setEditMode(false)
setSuccessMessage('Konfiguration gespeichert')
setTimeout(() => setSuccessMessage(null), 3000)
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen')
} finally {
setActionLoading(null)
}
}
const executeAction = async (action: 'start' | 'stop') => {
setActionLoading(action)
setError(null)
setSuccessMessage(null)
try {
const response = await fetch('/api/admin/night-mode/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (!response.ok) {
const errData = await response.json()
throw new Error(errData.error || `Fehler bei ${action}`)
}
const data = await response.json()
setSuccessMessage(data.message || `${action === 'start' ? 'Gestartet' : 'Gestoppt'}`)
setTimeout(() => setSuccessMessage(null), 5000)
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : `${action} fehlgeschlagen`)
} finally {
setActionLoading(null)
}
}
const toggleEnabled = async () => {
if (!editConfig) return
const newConfig = { ...editConfig, enabled: !editConfig.enabled }
setEditConfig(newConfig)
setActionLoading('toggle')
setError(null)
try {
const response = await fetch('/api/admin/night-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newConfig),
})
if (!response.ok) {
throw new Error('Fehler beim Umschalten')
}
setSuccessMessage(newConfig.enabled ? 'Nachtmodus aktiviert' : 'Nachtmodus deaktiviert')
setTimeout(() => setSuccessMessage(null), 3000)
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Umschalten fehlgeschlagen')
// Zuruecksetzen bei Fehler
setEditConfig({ ...editConfig })
} finally {
setActionLoading(null)
}
}
const cancelEdit = () => {
setEditMode(false)
setEditConfig(status?.config || null)
}
const runningCount = Object.values(status?.services_status || {}).filter(
s => s.toLowerCase() === 'running' || s.toLowerCase().includes('up')
).length
const stoppedCount = Object.values(status?.services_status || {}).filter(
s => s.toLowerCase() === 'exited' || s.toLowerCase().includes('exit')
).length
return {
status,
services,
loading,
actionLoading,
error,
successMessage,
editMode,
setEditMode,
editConfig,
setEditConfig,
saveConfig,
executeAction,
toggleEnabled,
cancelEdit,
runningCount,
stoppedCount,
}
}

View File

@@ -7,193 +7,32 @@
* nach Zeitplan. Manuelles Starten/Stoppen ebenfalls moeglich.
*/
import { useEffect, useState, useCallback } from 'react'
interface NightModeConfig {
enabled: boolean
shutdown_time: string
startup_time: string
last_action: string | null
last_action_time: string | null
excluded_services: string[]
}
interface NightModeStatus {
config: NightModeConfig
current_time: string
next_action: string | null
next_action_time: string | null
time_until_next_action: string | null
services_status: Record<string, string>
}
interface ServicesInfo {
all_services: string[]
excluded_services: string[]
status: Record<string, string>
}
import { useNightMode } from './_components/useNightMode'
import { MainControl } from './_components/MainControl'
import { StatusCards } from './_components/StatusCards'
import { TimeConfig } from './_components/TimeConfig'
import { ServiceList } from './_components/ServiceList'
import { InfoBox } from './_components/InfoBox'
export default function NightModePage() {
const [status, setStatus] = useState<NightModeStatus | null>(null)
const [services, setServices] = useState<ServicesInfo | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
// Lokale Konfiguration fuer Bearbeitung
const [editMode, setEditMode] = useState(false)
const [editConfig, setEditConfig] = useState<NightModeConfig | null>(null)
const fetchData = useCallback(async () => {
setError(null)
try {
const [statusRes, servicesRes] = await Promise.all([
fetch('/api/admin/night-mode'),
fetch('/api/admin/night-mode/services'),
])
if (statusRes.ok) {
const data = await statusRes.json()
setStatus(data)
if (!editMode) {
setEditConfig(data.config)
}
} else {
const errData = await statusRes.json()
setError(errData.error || 'Fehler beim Laden des Status')
}
if (servicesRes.ok) {
setServices(await servicesRes.json())
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindung zum Night-Scheduler fehlgeschlagen')
} finally {
setLoading(false)
}
}, [editMode])
useEffect(() => {
fetchData()
}, [fetchData])
// Auto-Refresh alle 30 Sekunden
useEffect(() => {
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const saveConfig = async () => {
if (!editConfig) return
setActionLoading('save')
setError(null)
setSuccessMessage(null)
try {
const response = await fetch('/api/admin/night-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editConfig),
})
if (!response.ok) {
const errData = await response.json()
throw new Error(errData.error || 'Fehler beim Speichern')
}
setEditMode(false)
setSuccessMessage('Konfiguration gespeichert')
setTimeout(() => setSuccessMessage(null), 3000)
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen')
} finally {
setActionLoading(null)
}
}
const executeAction = async (action: 'start' | 'stop') => {
setActionLoading(action)
setError(null)
setSuccessMessage(null)
try {
const response = await fetch('/api/admin/night-mode/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (!response.ok) {
const errData = await response.json()
throw new Error(errData.error || `Fehler bei ${action}`)
}
const data = await response.json()
setSuccessMessage(data.message || `${action === 'start' ? 'Gestartet' : 'Gestoppt'}`)
setTimeout(() => setSuccessMessage(null), 5000)
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : `${action} fehlgeschlagen`)
} finally {
setActionLoading(null)
}
}
const toggleEnabled = async () => {
if (!editConfig) return
const newConfig = { ...editConfig, enabled: !editConfig.enabled }
setEditConfig(newConfig)
setActionLoading('toggle')
setError(null)
try {
const response = await fetch('/api/admin/night-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newConfig),
})
if (!response.ok) {
throw new Error('Fehler beim Umschalten')
}
setSuccessMessage(newConfig.enabled ? 'Nachtmodus aktiviert' : 'Nachtmodus deaktiviert')
setTimeout(() => setSuccessMessage(null), 3000)
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Umschalten fehlgeschlagen')
// Zuruecksetzen bei Fehler
setEditConfig({ ...editConfig })
} finally {
setActionLoading(null)
}
}
const getServiceStatusColor = (state: string) => {
const lower = state.toLowerCase()
if (lower === 'running' || lower.includes('up')) {
return 'bg-green-100 text-green-800'
}
if (lower === 'exited' || lower.includes('exit')) {
return 'bg-slate-100 text-slate-600'
}
if (lower === 'paused' || lower.includes('pause')) {
return 'bg-yellow-100 text-yellow-800'
}
return 'bg-slate-100 text-slate-600'
}
const runningCount = Object.values(status?.services_status || {}).filter(
s => s.toLowerCase() === 'running' || s.toLowerCase().includes('up')
).length
const stoppedCount = Object.values(status?.services_status || {}).filter(
s => s.toLowerCase() === 'exited' || s.toLowerCase().includes('exit')
).length
const {
status,
services,
loading,
actionLoading,
error,
successMessage,
editMode,
setEditMode,
editConfig,
setEditConfig,
saveConfig,
executeAction,
toggleEnabled,
cancelEdit,
runningCount,
stoppedCount,
} = useNightMode()
return (
<div className="p-6">
@@ -226,334 +65,33 @@ export default function NightModePage() {
</div>
) : (
<>
{/* Haupt-Steuerung */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
{/* Toggle */}
<div className="flex items-center gap-6">
<div
onClick={toggleEnabled}
className={`relative inline-flex h-10 w-20 cursor-pointer items-center rounded-full transition-colors ${
editConfig?.enabled ? 'bg-orange-600' : 'bg-slate-300'
} ${actionLoading === 'toggle' ? 'opacity-50 cursor-wait' : ''}`}
>
<span
className={`inline-block h-8 w-8 transform rounded-full bg-white shadow-lg transition-transform ${
editConfig?.enabled ? 'translate-x-11' : 'translate-x-1'
}`}
/>
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">
Nachtmodus: {editConfig?.enabled ? 'Aktiv' : 'Inaktiv'}
</h2>
<p className="text-sm text-slate-500">
{editConfig?.enabled
? `Abschaltung um ${editConfig.shutdown_time}, Start um ${editConfig.startup_time}`
: 'Zeitgesteuerte Abschaltung ist deaktiviert'}
</p>
</div>
</div>
<MainControl
editConfig={editConfig}
actionLoading={actionLoading}
onToggle={toggleEnabled}
onExecute={executeAction}
/>
{/* Manuelle Aktionen */}
<div className="flex gap-3">
<button
onClick={() => executeAction('stop')}
disabled={actionLoading !== null}
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'stop' ? (
<span className="animate-spin">&#9696;</span>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
)}
Jetzt abschalten
</button>
<button
onClick={() => executeAction('start')}
disabled={actionLoading !== null}
className="flex items-center gap-2 px-5 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'start' ? (
<span className="animate-spin">&#9696;</span>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
Jetzt starten
</button>
</div>
</div>
</div>
<StatusCards
status={status}
runningCount={runningCount}
stoppedCount={stoppedCount}
/>
{/* Status-Karten */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{status?.current_time || '--:--'}</div>
<div className="text-sm text-slate-500">Aktuelle Zeit</div>
</div>
</div>
</div>
<TimeConfig
editMode={editMode}
editConfig={editConfig}
actionLoading={actionLoading}
status={status}
onSetEditMode={setEditMode}
onSetEditConfig={setEditConfig}
onSave={saveConfig}
onCancel={cancelEdit}
/>
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
status?.next_action === 'shutdown' ? 'bg-red-100' : 'bg-green-100'
}`}>
{status?.next_action === 'shutdown' ? (
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</div>
<div>
<div className="text-2xl font-bold text-slate-900">
{status?.next_action_time || '--:--'}
</div>
<div className="text-sm text-slate-500">
{status?.next_action === 'shutdown' ? 'Naechste Abschaltung' : 'Naechster Start'}
</div>
</div>
</div>
</div>
<ServiceList services={services} status={status} />
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-orange-100 flex items-center justify-center">
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">
{status?.time_until_next_action || '-'}
</div>
<div className="text-sm text-slate-500">Countdown</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center">
<svg className="w-6 h-6 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-green-600">{runningCount}</span>
<span className="text-slate-400">/</span>
<span className="text-lg font-bold text-slate-600">{stoppedCount}</span>
</div>
<div className="text-sm text-slate-500">Aktiv / Gestoppt</div>
</div>
</div>
</div>
</div>
{/* Zeitkonfiguration */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-semibold text-slate-900">Zeitkonfiguration</h3>
{editMode ? (
<div className="flex gap-2">
<button
onClick={() => {
setEditMode(false)
setEditConfig(status?.config || null)
}}
className="px-4 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
>
Abbrechen
</button>
<button
onClick={saveConfig}
disabled={actionLoading === 'save'}
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
>
{actionLoading === 'save' ? 'Speichern...' : 'Speichern'}
</button>
</div>
) : (
<button
onClick={() => setEditMode(true)}
className="px-4 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
>
Bearbeiten
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
Abschaltung um
</span>
</label>
{editMode ? (
<input
type="time"
value={editConfig?.shutdown_time || '22:00'}
onChange={e => setEditConfig(prev => prev ? { ...prev, shutdown_time: e.target.value } : null)}
className="w-full px-4 py-3 text-xl font-mono border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
) : (
<div className="text-3xl font-mono font-bold text-slate-900 bg-slate-50 px-4 py-3 rounded-lg">
{editConfig?.shutdown_time || '22:00'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="flex items-center gap-2">
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Start um
</span>
</label>
{editMode ? (
<input
type="time"
value={editConfig?.startup_time || '06:00'}
onChange={e => setEditConfig(prev => prev ? { ...prev, startup_time: e.target.value } : null)}
className="w-full px-4 py-3 text-xl font-mono border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
) : (
<div className="text-3xl font-mono font-bold text-slate-900 bg-slate-50 px-4 py-3 rounded-lg">
{editConfig?.startup_time || '06:00'}
</div>
)}
</div>
</div>
{/* Letzte Aktion */}
{status?.config.last_action && (
<div className="mt-6 pt-6 border-t border-slate-200">
<div className="text-sm text-slate-500">
Letzte Aktion:{' '}
<span className={`font-semibold ${status.config.last_action === 'startup' ? 'text-green-600' : 'text-red-600'}`}>
{status.config.last_action === 'startup' ? 'Gestartet' : 'Abgeschaltet'}
</span>
{status.config.last_action_time && (
<span className="ml-2">
am {new Date(status.config.last_action_time).toLocaleString('de-DE')}
</span>
)}
</div>
</div>
)}
</div>
{/* Service-Liste */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Services</h3>
<p className="text-sm text-slate-500 mt-1">
Gruen = wird verwaltet, Grau = ausgeschlossen (laeuft immer)
</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{services?.all_services.map(service => {
const serviceStatus = status?.services_status[service] || 'unknown'
const isExcluded = services.excluded_services.includes(service)
return (
<div
key={service}
className={`flex items-center justify-between px-4 py-3 rounded-lg border ${
isExcluded
? 'bg-slate-50 border-slate-200'
: 'bg-orange-50 border-orange-200'
}`}
>
<div className="flex items-center gap-2">
{isExcluded ? (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span className={`text-sm font-medium ${isExcluded ? 'text-slate-500' : 'text-slate-900'}`}>
{service}
</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${getServiceStatusColor(serviceStatus)}`}>
{serviceStatus}
</span>
</div>
)
})}
{/* Auch excluded Services anzeigen, die nicht in all_services sind */}
{services?.excluded_services
.filter(s => !services.all_services.includes(s))
.map(service => (
<div
key={service}
className="flex items-center justify-between px-4 py-3 rounded-lg border bg-slate-50 border-slate-200"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-sm font-medium text-slate-500">
{service}
</span>
</div>
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-slate-100 text-slate-600">
excluded
</span>
</div>
))}
</div>
</div>
</div>
{/* Info Box */}
<div className="mt-6 bg-orange-50 border border-orange-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-semibold text-orange-900">Hinweise zur Nachtabschaltung</h4>
<ul className="text-sm text-orange-800 mt-1 space-y-1 list-disc list-inside">
<li>Der <strong>night-scheduler</strong> und <strong>nginx</strong> bleiben immer aktiv</li>
<li>Services werden mit <code>docker compose stop</code> angehalten (Daten bleiben erhalten)</li>
<li>Bei manuellem Start/Stop wird die letzte Aktion gespeichert</li>
<li>Der Scheduler prueft jede Minute, ob eine Aktion faellig ist</li>
</ul>
</div>
</div>
</div>
<InfoBox />
</>
)}
</div>

View File

@@ -0,0 +1,54 @@
import type { CategoryType, CategoryItem } from '../types'
interface CategoryFilterProps {
categories: CategoryItem[]
activeCategory: CategoryType
setActiveCategory: (cat: CategoryType) => void
searchTerm: string
setSearchTerm: (term: string) => void
}
export function CategoryFilter({
categories,
activeCategory,
setActiveCategory,
searchTerm,
setSearchTerm,
}: CategoryFilterProps) {
return (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
{/* Category Tabs */}
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id as CategoryType)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.id
? 'bg-orange-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{cat.name} ({cat.count})
</button>
))}
</div>
{/* Search */}
<div className="relative w-full md:w-64">
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import type { Component } from '../types'
import { getCategoryColor, getLicenseColor } from './utils'
interface ComponentsTableProps {
components: Component[]
loading: boolean
}
export function ComponentsTable({ components, loading }: ComponentsTableProps) {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
<span className="ml-3 text-gray-600">Lade SBOM...</span>
</div>
)
}
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider min-w-[300px]">Verwendungszweck</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Port</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lizenz</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{components.map((component, idx) => {
const licenseId = component.license || component.licenses?.[0]?.license?.id
return (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-4">
<div className="text-sm font-medium text-gray-900">{component.name}</div>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className="text-sm font-mono text-gray-900">{component.version}</span>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
{component.category || component.type}
</span>
</td>
<td className="px-4 py-4">
{component.description ? (
<div className="text-sm text-gray-600 leading-relaxed">{component.description}</div>
) : (
<span className="text-sm text-gray-400 italic">Keine Beschreibung</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.port ? (
<span className="text-sm font-mono text-gray-600">{component.port}</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{licenseId ? (
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
{licenseId}
</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.sourceUrl && component.sourceUrl !== '-' ? (
<a
href={component.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 hover:text-orange-800 text-sm flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
GitHub
</a>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
{components.length === 0 && (
<div className="p-8 text-center text-gray-500">
Keine Komponenten gefunden.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,19 @@
interface ExportButtonProps {
onExport: () => void
}
export function ExportButton({ onExport }: ExportButtonProps) {
return (
<div className="mt-6 flex justify-end">
<button
onClick={onExport}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
SBOM exportieren (JSON)
</button>
</div>
)
}

View File

@@ -0,0 +1,251 @@
import type { InfoTabType } from '../types'
interface InfoTabsSectionProps {
activeInfoTab: InfoTabType
setActiveInfoTab: (tab: InfoTabType) => void
showFullDocs: boolean
setShowFullDocs: (show: boolean) => void
}
export function InfoTabsSection({
activeInfoTab,
setActiveInfoTab,
showFullDocs,
setShowFullDocs,
}: InfoTabsSectionProps) {
return (
<div className="mt-8">
<div className="bg-white rounded-lg shadow">
{/* Tab Headers */}
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveInfoTab('audit')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeInfoTab === 'audit'
? 'border-orange-500 text-orange-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Audit
</span>
</button>
<button
onClick={() => setActiveInfoTab('documentation')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeInfoTab === 'documentation'
? 'border-orange-500 text-orange-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Dokumentation
</span>
</button>
</nav>
</div>
{/* Tab Content */}
<div className="p-6">
{activeInfoTab === 'audit' && <AuditTab />}
{activeInfoTab === 'documentation' && (
<DocumentationTab showFullDocs={showFullDocs} setShowFullDocs={setShowFullDocs} />
)}
</div>
</div>
</div>
)
}
function AuditTab() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SBOM Status */}
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
SBOM Status
</h3>
<div className="space-y-3">
{[
{ label: 'Letzte Generierung', value: 'CI/CD' },
{ label: 'Format', value: 'CycloneDX 1.5' },
{ label: 'Komponenten', value: 'Alle erfasst' },
{ label: 'Transitive Deps', value: 'Inkludiert' },
].map((item) => (
<div key={item.label} className="flex items-center justify-between">
<span className="text-sm text-slate-600">{item.label}</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">{item.value}</span>
</span>
</div>
))}
</div>
</div>
{/* License Compliance */}
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
</svg>
License Compliance
</h3>
<div className="space-y-3">
{[
{ label: 'Erlaubte Lizenzen', value: 'MIT, Apache, BSD', color: 'bg-green-500' },
{ label: 'Copyleft (GPL)', value: '0', color: 'bg-green-500' },
{ label: 'Unbekannte Lizenzen', value: '0', color: 'bg-green-500' },
{ label: 'Kommerzielle', value: 'Review erforderlich', color: 'bg-yellow-500' },
].map((item) => (
<div key={item.label} className="flex items-center justify-between">
<span className="text-sm text-slate-600">{item.label}</span>
<span className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${item.color}`}></span>
<span className="text-sm font-medium">{item.value}</span>
</span>
</div>
))}
</div>
</div>
</div>
)
}
function DocumentationTab({
showFullDocs,
setShowFullDocs,
}: {
showFullDocs: boolean
setShowFullDocs: (show: boolean) => void
}) {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-slate-800">SBOM Dokumentation</h3>
<button
onClick={() => setShowFullDocs(!showFullDocs)}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2"
>
<svg className={`w-4 h-4 transition-transform ${showFullDocs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
{showFullDocs ? 'Weniger anzeigen' : 'Vollstaendige Dokumentation'}
</button>
</div>
{!showFullDocs ? <DocsSummary /> : <DocsFullContent />}
</div>
)
}
function DocsSummary() {
return (
<div className="prose prose-slate max-w-none">
<p className="text-slate-600">
Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.
Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div className="bg-blue-50 p-4 rounded-lg">
<h4 className="font-medium text-blue-800 mb-2">Generator</h4>
<p className="text-sm text-blue-600">Syft (Primary), Trivy (Validation)</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<h4 className="font-medium text-purple-800 mb-2">Format</h4>
<p className="text-sm text-purple-600">CycloneDX 1.5, SPDX</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<h4 className="font-medium text-green-800 mb-2">Retention</h4>
<p className="text-sm text-green-600">5 Jahre (Compliance)</p>
</div>
</div>
</div>
)
}
function DocsFullContent() {
return (
<div className="prose prose-slate max-w-none bg-slate-50 p-6 rounded-lg overflow-auto max-h-[600px]">
<h2>Software Bill of Materials (SBOM)</h2>
<h3>1. Uebersicht</h3>
<p>Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten. Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.</p>
<h3>2. SBOM-Generierung</h3>
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
{`Source Code
|
v
+---------------------------------------------------------------+
| SBOM Generators |
| +-------------+ +-------------+ +---------------------+ |
| | Syft | | Trivy | | Native Tooling | |
| | (Primary) | | (Validation)| | (npm, go mod, pip) | |
| +------+------+ +------+------+ +----------+----------+ |
+---------+----------------+--------------------+---------------+
| | |
+----------------+--------------------+
|
v
+----------------+
| CycloneDX |
| Format |
+----------------+`}
</pre>
<h3>3. Erfasste Komponenten</h3>
<table className="min-w-full">
<thead>
<tr>
<th className="text-left">Typ</th>
<th className="text-left">Quelle</th>
<th className="text-left">Beispiele</th>
</tr>
</thead>
<tbody>
<tr><td>npm packages</td><td>package-lock.json</td><td>react, next, tailwindcss</td></tr>
<tr><td>Go modules</td><td>go.sum</td><td>gin, gorm, jwt-go</td></tr>
<tr><td>Python packages</td><td>requirements.txt</td><td>fastapi, pydantic, httpx</td></tr>
<tr><td>Container Images</td><td>Dockerfile</td><td>node:20-alpine, postgres:16</td></tr>
<tr><td>OS Packages</td><td>apk, apt</td><td>openssl, libpq</td></tr>
</tbody>
</table>
<h3>4. License Compliance</h3>
<table className="min-w-full">
<thead>
<tr>
<th className="text-left">Kategorie</th>
<th className="text-left">Lizenzen</th>
<th className="text-left">Status</th>
</tr>
</thead>
<tbody>
<tr><td>Permissive (erlaubt)</td><td>MIT, Apache 2.0, BSD, ISC</td><td className="text-green-600">OK</td></tr>
<tr><td>Weak Copyleft</td><td>LGPL, MPL</td><td className="text-yellow-600">Review</td></tr>
<tr><td>Strong Copyleft</td><td>GPL, AGPL</td><td className="text-red-600">Nicht erlaubt</td></tr>
<tr><td>Proprietaer</td><td>Commercial</td><td className="text-yellow-600">Genehmigung</td></tr>
</tbody>
</table>
<h3>5. Aufbewahrung & Compliance</h3>
<ul>
<li><strong>Retention:</strong> 5 Jahre (Compliance)</li>
<li><strong>Format:</strong> JSON + PDF Report</li>
<li><strong>Signierung:</strong> Digital signiert</li>
<li><strong>Audit:</strong> Jederzeit abrufbar</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import type { SBOMData } from '../types'
interface SbomMetadataProps {
sbomData: SBOMData
}
export function SbomMetadata({ sbomData }: SbomMetadataProps) {
if (!sbomData.metadata) return null
return (
<div className="bg-slate-50 rounded-lg p-4 mb-6 text-sm">
<div className="flex flex-wrap gap-6">
<div>
<span className="text-slate-500">Format:</span>
<span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span>
</div>
<div>
<span className="text-slate-500">Generiert:</span>
<span className="ml-2 font-medium">
{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}
</span>
</div>
<div>
<span className="text-slate-500">Anwendung:</span>
<span className="ml-2 font-medium">
{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}
</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import type { SbomStats } from '../types'
interface StatsCardsProps {
stats: SbomStats
}
export function StatsCards({ stats }: StatsCardsProps) {
const items = [
{ value: stats.totalAll, label: 'Komponenten Total', color: 'text-slate-800' },
{ value: stats.totalInfra, label: 'Docker Services', color: 'text-purple-600' },
{ value: stats.totalSecurityTools, label: 'Security Tools', color: 'text-red-600' },
{ value: stats.totalPython, label: 'Python', color: 'text-emerald-600' },
{ value: stats.totalGo, label: 'Go', color: 'text-sky-600' },
{ value: stats.totalNode, label: 'Node.js', color: 'text-lime-600' },
{ value: stats.totalUnity, label: 'Unity', color: 'text-amber-600' },
{ value: stats.totalCsharp, label: 'C#', color: 'text-fuchsia-600' },
{ value: stats.databases, label: 'Datenbanken', color: 'text-blue-600' },
{ value: stats.game, label: 'Game', color: 'text-rose-600' },
]
return (
<div className="grid grid-cols-2 md:grid-cols-5 lg:grid-cols-10 gap-4 mb-6">
{items.map((item) => (
<div key={item.label} className="bg-white rounded-lg shadow p-4">
<div className={`text-3xl font-bold ${item.color}`}>{item.value}</div>
<div className="text-sm text-slate-500">{item.label}</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { StatsCards } from './StatsCards'
export { CategoryFilter } from './CategoryFilter'
export { ComponentsTable } from './ComponentsTable'
export { ExportButton } from './ExportButton'
export { SbomMetadata } from './SbomMetadata'
export { InfoTabsSection } from './InfoTabsSection'

View File

@@ -0,0 +1,32 @@
export function getCategoryColor(category?: string): string {
switch (category) {
case 'database': return 'bg-blue-100 text-blue-800'
case 'security': return 'bg-purple-100 text-purple-800'
case 'security-tool': return 'bg-red-100 text-red-800'
case 'application': return 'bg-green-100 text-green-800'
case 'communication': return 'bg-yellow-100 text-yellow-800'
case 'storage': return 'bg-orange-100 text-orange-800'
case 'search': return 'bg-pink-100 text-pink-800'
case 'cache': return 'bg-cyan-100 text-cyan-800'
case 'development': return 'bg-gray-100 text-gray-800'
case 'cicd': return 'bg-orange-100 text-orange-800'
case 'python': return 'bg-emerald-100 text-emerald-800'
case 'go': return 'bg-sky-100 text-sky-800'
case 'nodejs': return 'bg-lime-100 text-lime-800'
case 'unity': return 'bg-amber-100 text-amber-800'
case 'csharp': return 'bg-fuchsia-100 text-fuchsia-800'
case 'game': return 'bg-rose-100 text-rose-800'
case 'voice': return 'bg-teal-100 text-teal-800'
case 'qa': return 'bg-blue-100 text-blue-800'
default: return 'bg-slate-100 text-slate-800'
}
}
export function getLicenseColor(license?: string): string {
if (!license) return 'bg-gray-100 text-gray-600'
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
return 'bg-gray-100 text-gray-600'
}

View File

@@ -0,0 +1,169 @@
import type { Component } from './types'
// Infrastructure components from docker-compose.yml and project analysis
export const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== DATABASES =====
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
// ===== CACHE & QUEUE =====
{ type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
// ===== SEARCH ENGINES =====
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
// ===== OBJECT STORAGE =====
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
// ===== SECURITY =====
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
// ===== COMMUNICATION =====
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
{ type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' },
// ===== APPLICATION SERVICES (Python) =====
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Lehrer Backend API (Klausuren, E-Mail, Alerts)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Go) =====
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Node.js) =====
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3002', description: 'Admin Lehrer Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
// ===== CI/CD & VERSION CONTROL =====
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
// ===== DEVELOPMENT =====
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
// ===== GAME (Breakpilot Drive) =====
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
// ===== VOICE SERVICE =====
{ type: 'service', name: 'Voice Service (FastAPI)', version: '1.0', category: 'voice', port: '8091', description: 'Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'PersonaPlex-7B (NVIDIA)', version: '7B', category: 'voice', port: '8998', description: 'Full-Duplex Speech-to-Speech (Produktion)', license: 'MIT/NVIDIA Open Model', sourceUrl: 'https://developer.nvidia.com' },
{ type: 'service', name: 'TaskOrchestrator', version: '1.0', category: 'voice', port: '-', description: 'Agent-Orchestrierung mit Task State Machine', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Mimi Audio Codec', version: '1.0', category: 'voice', port: '-', description: 'Audio Streaming (24kHz, 80ms Frames)', license: 'MIT', sourceUrl: '-' },
// ===== BQAS (Quality Assurance) =====
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' },
]
// Security Tools discovered in project
export const SECURITY_TOOLS: Component[] = [
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
]
// Key Python packages (from requirements.txt)
export const PYTHON_PACKAGES: Component[] = [
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
{ type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' },
{ type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' },
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
{ type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' },
{ type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' },
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
{ type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' },
{ type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' },
{ type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' },
{ type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' },
{ type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' },
{ type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' },
{ type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' },
{ type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' },
{ type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' },
{ type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' },
{ type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' },
{ type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' },
{ type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' },
{ type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' },
{ type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' },
]
// Key Go modules (from go.mod files)
export const GO_MODULES: Component[] = [
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
{ type: 'library', name: 'opensearch-project/opensearch-go', version: '4.x', category: 'go', description: 'OpenSearch Client (edu-search-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/opensearch-go' },
{ type: 'library', name: 'lib/pq', version: '1.10+', category: 'go', description: 'PostgreSQL Driver (school-service)', license: 'MIT', sourceUrl: 'https://github.com/lib/pq' },
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
]
// Key Node.js packages (from package.json files)
export const NODE_PACKAGES: Component[] = [
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Admin Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
]
// Unity packages (Breakpilot Drive game engine)
export const UNITY_PACKAGES: Component[] = [
{ type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' },
{ type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' },
{ type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' },
{ type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' },
{ type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' },
{ type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' },
{ type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' },
]
// C# dependencies (Breakpilot Drive)
export const CSHARP_PACKAGES: Component[] = [
{ type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' },
{ type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' },
{ type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' },
]

View File

@@ -13,371 +13,36 @@
* - Version tracking
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
interface Component {
type: string
name: string
version: string
purl?: string
licenses?: { license: { id: string } }[]
category?: string
port?: string
description?: string
license?: string
sourceUrl?: string
}
interface SBOMData {
bomFormat?: string
specVersion?: string
version?: number
metadata?: {
timestamp?: string
tools?: { vendor: string; name: string; version: string }[]
component?: { type: string; name: string; version: string }
}
components?: Component[]
}
type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp'
type InfoTabType = 'audit' | 'documentation'
// Infrastructure components from docker-compose.yml and project analysis
const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== DATABASES =====
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
// ===== CACHE & QUEUE =====
{ type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
// ===== SEARCH ENGINES =====
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
// ===== OBJECT STORAGE =====
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
// ===== SECURITY =====
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
// ===== COMMUNICATION =====
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
{ type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' },
// ===== APPLICATION SERVICES (Python) =====
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Lehrer Backend API (Klausuren, E-Mail, Alerts)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Go) =====
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
// ===== APPLICATION SERVICES (Node.js) =====
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3002', description: 'Admin Lehrer Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
// ===== CI/CD & VERSION CONTROL =====
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
// ===== DEVELOPMENT =====
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
// ===== GAME (Breakpilot Drive) =====
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
// ===== VOICE SERVICE =====
{ type: 'service', name: 'Voice Service (FastAPI)', version: '1.0', category: 'voice', port: '8091', description: 'Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'PersonaPlex-7B (NVIDIA)', version: '7B', category: 'voice', port: '8998', description: 'Full-Duplex Speech-to-Speech (Produktion)', license: 'MIT/NVIDIA Open Model', sourceUrl: 'https://developer.nvidia.com' },
{ type: 'service', name: 'TaskOrchestrator', version: '1.0', category: 'voice', port: '-', description: 'Agent-Orchestrierung mit Task State Machine', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'Mimi Audio Codec', version: '1.0', category: 'voice', port: '-', description: 'Audio Streaming (24kHz, 80ms Frames)', license: 'MIT', sourceUrl: '-' },
// ===== BQAS (Quality Assurance) =====
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' },
{ type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' },
]
// Security Tools discovered in project
const SECURITY_TOOLS: Component[] = [
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
]
// Key Python packages (from requirements.txt)
const PYTHON_PACKAGES: Component[] = [
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
{ type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' },
{ type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' },
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
{ type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' },
{ type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' },
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
{ type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' },
{ type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' },
{ type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' },
{ type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' },
{ type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' },
{ type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' },
{ type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' },
{ type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' },
{ type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' },
{ type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' },
{ type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' },
{ type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' },
{ type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' },
{ type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' },
{ type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' },
]
// Key Go modules (from go.mod files)
const GO_MODULES: Component[] = [
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
{ type: 'library', name: 'opensearch-project/opensearch-go', version: '4.x', category: 'go', description: 'OpenSearch Client (edu-search-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/opensearch-go' },
{ type: 'library', name: 'lib/pq', version: '1.10+', category: 'go', description: 'PostgreSQL Driver (school-service)', license: 'MIT', sourceUrl: 'https://github.com/lib/pq' },
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
]
// Key Node.js packages (from package.json files)
const NODE_PACKAGES: Component[] = [
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Admin Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
]
// Unity packages (Breakpilot Drive game engine)
const UNITY_PACKAGES: Component[] = [
{ type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' },
{ type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' },
{ type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' },
{ type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' },
{ type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' },
{ type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' },
{ type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' },
]
// C# dependencies (Breakpilot Drive)
const CSHARP_PACKAGES: Component[] = [
{ type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' },
{ type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' },
{ type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' },
]
import {
StatsCards,
CategoryFilter,
ComponentsTable,
ExportButton,
SbomMetadata,
InfoTabsSection,
} from './_components'
import { useSbomData } from './useSbomData'
export default function SBOMPage() {
const [sbomData, setSbomData] = useState<SBOMData | null>(null)
const [loading, setLoading] = useState(true)
const [activeCategory, setActiveCategory] = useState<CategoryType>('all')
const [searchTerm, setSearchTerm] = useState('')
const [activeInfoTab, setActiveInfoTab] = useState<InfoTabType>('audit')
const [showFullDocs, setShowFullDocs] = useState(false)
useEffect(() => {
loadSBOM()
}, [])
const loadSBOM = async () => {
setLoading(true)
try {
const res = await fetch('/api/v1/security/sbom')
if (res.ok) {
const data = await res.json()
setSbomData(data)
}
} catch (error) {
console.error('Failed to load SBOM:', error)
} finally {
setLoading(false)
}
}
const getAllComponents = (): Component[] => {
const infraComponents = INFRASTRUCTURE_COMPONENTS.map(c => ({
...c,
category: c.category || 'infrastructure'
}))
const securityToolsComponents = SECURITY_TOOLS.map(c => ({
...c,
category: c.category || 'security-tool'
}))
const pythonComponents = PYTHON_PACKAGES.map(c => ({
...c,
category: 'python'
}))
const goComponents = GO_MODULES.map(c => ({
...c,
category: 'go'
}))
const nodeComponents = NODE_PACKAGES.map(c => ({
...c,
category: 'nodejs'
}))
const unityComponents = UNITY_PACKAGES.map(c => ({
...c,
category: 'unity'
}))
const csharpComponents = CSHARP_PACKAGES.map(c => ({
...c,
category: 'csharp'
}))
// Add dynamic SBOM data from backend if available
const dynamicPython = (sbomData?.components || []).map(c => ({
...c,
category: 'python'
}))
return [...infraComponents, ...securityToolsComponents, ...pythonComponents, ...goComponents, ...nodeComponents, ...unityComponents, ...csharpComponents, ...dynamicPython]
}
const getFilteredComponents = () => {
let components = getAllComponents()
if (activeCategory !== 'all') {
if (activeCategory === 'infrastructure') {
components = INFRASTRUCTURE_COMPONENTS
} else if (activeCategory === 'security-tools') {
components = SECURITY_TOOLS
} else if (activeCategory === 'python') {
components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
} else if (activeCategory === 'go') {
components = GO_MODULES
} else if (activeCategory === 'nodejs') {
components = NODE_PACKAGES
} else if (activeCategory === 'unity') {
components = UNITY_PACKAGES
} else if (activeCategory === 'csharp') {
components = CSHARP_PACKAGES
}
}
if (searchTerm) {
components = components.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.version.toLowerCase().includes(searchTerm.toLowerCase()) ||
(c.description?.toLowerCase().includes(searchTerm.toLowerCase()))
)
}
return components
}
const getCategoryColor = (category?: string) => {
switch (category) {
case 'database': return 'bg-blue-100 text-blue-800'
case 'security': return 'bg-purple-100 text-purple-800'
case 'security-tool': return 'bg-red-100 text-red-800'
case 'application': return 'bg-green-100 text-green-800'
case 'communication': return 'bg-yellow-100 text-yellow-800'
case 'storage': return 'bg-orange-100 text-orange-800'
case 'search': return 'bg-pink-100 text-pink-800'
case 'cache': return 'bg-cyan-100 text-cyan-800'
case 'development': return 'bg-gray-100 text-gray-800'
case 'cicd': return 'bg-orange-100 text-orange-800'
case 'python': return 'bg-emerald-100 text-emerald-800'
case 'go': return 'bg-sky-100 text-sky-800'
case 'nodejs': return 'bg-lime-100 text-lime-800'
case 'unity': return 'bg-amber-100 text-amber-800'
case 'csharp': return 'bg-fuchsia-100 text-fuchsia-800'
case 'game': return 'bg-rose-100 text-rose-800'
case 'voice': return 'bg-teal-100 text-teal-800'
case 'qa': return 'bg-blue-100 text-blue-800'
default: return 'bg-slate-100 text-slate-800'
}
}
const getLicenseColor = (license?: string) => {
if (!license) return 'bg-gray-100 text-gray-600'
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
return 'bg-gray-100 text-gray-600'
}
const stats = {
totalInfra: INFRASTRUCTURE_COMPONENTS.length,
totalSecurityTools: SECURITY_TOOLS.length,
totalPython: PYTHON_PACKAGES.length + (sbomData?.components?.length || 0),
totalGo: GO_MODULES.length,
totalNode: NODE_PACKAGES.length,
totalUnity: UNITY_PACKAGES.length,
totalCsharp: CSHARP_PACKAGES.length,
totalAll: INFRASTRUCTURE_COMPONENTS.length + SECURITY_TOOLS.length + PYTHON_PACKAGES.length + GO_MODULES.length + NODE_PACKAGES.length + UNITY_PACKAGES.length + CSHARP_PACKAGES.length + (sbomData?.components?.length || 0),
databases: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'database').length,
services: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'application').length,
communication: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'communication').length,
game: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'game').length,
}
const categories = [
{ id: 'all', name: 'Alle', count: stats.totalAll },
{ id: 'infrastructure', name: 'Infrastruktur', count: stats.totalInfra },
{ id: 'security-tools', name: 'Security Tools', count: stats.totalSecurityTools },
{ id: 'python', name: 'Python', count: stats.totalPython },
{ id: 'go', name: 'Go', count: stats.totalGo },
{ id: 'nodejs', name: 'Node.js', count: stats.totalNode },
{ id: 'unity', name: 'Unity', count: stats.totalUnity },
{ id: 'csharp', name: 'C#', count: stats.totalCsharp },
]
const filteredComponents = getFilteredComponents()
const {
sbomData,
loading,
activeCategory,
setActiveCategory,
searchTerm,
setSearchTerm,
activeInfoTab,
setActiveInfoTab,
showFullDocs,
setShowFullDocs,
filteredComponents,
stats,
categories,
handleExport,
} = useSbomData()
return (
<div>
@@ -398,7 +63,6 @@ export default function SBOMPage() {
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="sbom" />
{/* Wizard Link */}
@@ -414,467 +78,28 @@ export default function SBOMPage() {
</Link>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 lg:grid-cols-10 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-slate-800">{stats.totalAll}</div>
<div className="text-sm text-slate-500">Komponenten Total</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-purple-600">{stats.totalInfra}</div>
<div className="text-sm text-slate-500">Docker Services</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-red-600">{stats.totalSecurityTools}</div>
<div className="text-sm text-slate-500">Security Tools</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-emerald-600">{stats.totalPython}</div>
<div className="text-sm text-slate-500">Python</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-sky-600">{stats.totalGo}</div>
<div className="text-sm text-slate-500">Go</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-lime-600">{stats.totalNode}</div>
<div className="text-sm text-slate-500">Node.js</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-amber-600">{stats.totalUnity}</div>
<div className="text-sm text-slate-500">Unity</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-fuchsia-600">{stats.totalCsharp}</div>
<div className="text-sm text-slate-500">C#</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-blue-600">{stats.databases}</div>
<div className="text-sm text-slate-500">Datenbanken</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-rose-600">{stats.game}</div>
<div className="text-sm text-slate-500">Game</div>
</div>
</div>
<StatsCards stats={stats} />
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
{/* Category Tabs */}
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id as CategoryType)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.id
? 'bg-orange-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{cat.name} ({cat.count})
</button>
))}
</div>
<CategoryFilter
categories={categories}
activeCategory={activeCategory}
setActiveCategory={setActiveCategory}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
{/* Search */}
<div className="relative w-full md:w-64">
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
{sbomData && <SbomMetadata sbomData={sbomData} />}
{/* SBOM Metadata */}
{sbomData?.metadata && (
<div className="bg-slate-50 rounded-lg p-4 mb-6 text-sm">
<div className="flex flex-wrap gap-6">
<div>
<span className="text-slate-500">Format:</span>
<span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span>
</div>
<div>
<span className="text-slate-500">Generiert:</span>
<span className="ml-2 font-medium">
{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}
</span>
</div>
<div>
<span className="text-slate-500">Anwendung:</span>
<span className="ml-2 font-medium">
{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}
</span>
</div>
</div>
</div>
)}
<ComponentsTable components={filteredComponents} loading={loading} />
{/* Components Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
<span className="ml-3 text-gray-600">Lade SBOM...</span>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider min-w-[300px]">Verwendungszweck</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Port</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lizenz</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredComponents.map((component, idx) => {
// Get license from either the new license field or the old licenses array
const licenseId = component.license || component.licenses?.[0]?.license?.id
<ExportButton onExport={handleExport} />
return (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-4">
<div className="text-sm font-medium text-gray-900">{component.name}</div>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className="text-sm font-mono text-gray-900">{component.version}</span>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
{component.category || component.type}
</span>
</td>
<td className="px-4 py-4">
{component.description ? (
<div className="text-sm text-gray-600 leading-relaxed">{component.description}</div>
) : (
<span className="text-sm text-gray-400 italic">Keine Beschreibung</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.port ? (
<span className="text-sm font-mono text-gray-600">{component.port}</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{licenseId ? (
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
{licenseId}
</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
{component.sourceUrl && component.sourceUrl !== '-' ? (
<a
href={component.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 hover:text-orange-800 text-sm flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
GitHub
</a>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
{filteredComponents.length === 0 && (
<div className="p-8 text-center text-gray-500">
Keine Komponenten gefunden.
</div>
)}
</div>
)}
{/* Export Button */}
<div className="mt-6 flex justify-end">
<button
onClick={() => {
const data = JSON.stringify({
...sbomData,
infrastructure: INFRASTRUCTURE_COMPONENTS
}, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `breakpilot-lehrer-sbom-${new Date().toISOString().split('T')[0]}.json`
a.click()
}}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
SBOM exportieren (JSON)
</button>
</div>
{/* Info Tabs Section */}
<div className="mt-8">
<div className="bg-white rounded-lg shadow">
{/* Tab Headers */}
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveInfoTab('audit')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeInfoTab === 'audit'
? 'border-orange-500 text-orange-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Audit
</span>
</button>
<button
onClick={() => setActiveInfoTab('documentation')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeInfoTab === 'documentation'
? 'border-orange-500 text-orange-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Dokumentation
</span>
</button>
</nav>
</div>
{/* Tab Content */}
<div className="p-6">
{/* Audit Tab */}
{activeInfoTab === 'audit' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SBOM Status */}
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
SBOM Status
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Letzte Generierung</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">CI/CD</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Format</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">CycloneDX 1.5</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Komponenten</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">Alle erfasst</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Transitive Deps</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">Inkludiert</span>
</span>
</div>
</div>
</div>
{/* License Compliance */}
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
</svg>
License Compliance
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Erlaubte Lizenzen</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">MIT, Apache, BSD</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Copyleft (GPL)</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">0</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Unbekannte Lizenzen</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="text-sm font-medium">0</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Kommerzielle</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-yellow-500"></span>
<span className="text-sm font-medium">Review erforderlich</span>
</span>
</div>
</div>
</div>
</div>
)}
{/* Documentation Tab */}
{activeInfoTab === 'documentation' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-slate-800">SBOM Dokumentation</h3>
<button
onClick={() => setShowFullDocs(!showFullDocs)}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2"
>
<svg className={`w-4 h-4 transition-transform ${showFullDocs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
{showFullDocs ? 'Weniger anzeigen' : 'Vollstaendige Dokumentation'}
</button>
</div>
{!showFullDocs ? (
<div className="prose prose-slate max-w-none">
<p className="text-slate-600">
Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.
Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div className="bg-blue-50 p-4 rounded-lg">
<h4 className="font-medium text-blue-800 mb-2">Generator</h4>
<p className="text-sm text-blue-600">Syft (Primary), Trivy (Validation)</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<h4 className="font-medium text-purple-800 mb-2">Format</h4>
<p className="text-sm text-purple-600">CycloneDX 1.5, SPDX</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<h4 className="font-medium text-green-800 mb-2">Retention</h4>
<p className="text-sm text-green-600">5 Jahre (Compliance)</p>
</div>
</div>
</div>
) : (
<div className="prose prose-slate max-w-none bg-slate-50 p-6 rounded-lg overflow-auto max-h-[600px]">
<h2>Software Bill of Materials (SBOM)</h2>
<h3>1. Uebersicht</h3>
<p>Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten. Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.</p>
<h3>2. SBOM-Generierung</h3>
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
{`Source Code
v
┌───────────────────────────────────────────────────────────────┐
│ SBOM Generators │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Syft │ │ Trivy │ │ Native Tooling │ │
│ │ (Primary) │ │ (Validation)│ │ (npm, go mod, pip) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼────────────────────┼───────────────┘
│ │ │
└────────────────┴────────────────────┘
v
┌────────────────┐
│ CycloneDX │
│ Format │
└────────────────┘`}
</pre>
<h3>3. Erfasste Komponenten</h3>
<table className="min-w-full">
<thead>
<tr>
<th className="text-left">Typ</th>
<th className="text-left">Quelle</th>
<th className="text-left">Beispiele</th>
</tr>
</thead>
<tbody>
<tr><td>npm packages</td><td>package-lock.json</td><td>react, next, tailwindcss</td></tr>
<tr><td>Go modules</td><td>go.sum</td><td>gin, gorm, jwt-go</td></tr>
<tr><td>Python packages</td><td>requirements.txt</td><td>fastapi, pydantic, httpx</td></tr>
<tr><td>Container Images</td><td>Dockerfile</td><td>node:20-alpine, postgres:16</td></tr>
<tr><td>OS Packages</td><td>apk, apt</td><td>openssl, libpq</td></tr>
</tbody>
</table>
<h3>4. License Compliance</h3>
<table className="min-w-full">
<thead>
<tr>
<th className="text-left">Kategorie</th>
<th className="text-left">Lizenzen</th>
<th className="text-left">Status</th>
</tr>
</thead>
<tbody>
<tr><td>Permissive (erlaubt)</td><td>MIT, Apache 2.0, BSD, ISC</td><td className="text-green-600">OK</td></tr>
<tr><td>Weak Copyleft</td><td>LGPL, MPL</td><td className="text-yellow-600">Review</td></tr>
<tr><td>Strong Copyleft</td><td>GPL, AGPL</td><td className="text-red-600">Nicht erlaubt</td></tr>
<tr><td>Proprietaer</td><td>Commercial</td><td className="text-yellow-600">Genehmigung</td></tr>
</tbody>
</table>
<h3>5. Aufbewahrung & Compliance</h3>
<ul>
<li><strong>Retention:</strong> 5 Jahre (Compliance)</li>
<li><strong>Format:</strong> JSON + PDF Report</li>
<li><strong>Signierung:</strong> Digital signiert</li>
<li><strong>Audit:</strong> Jederzeit abrufbar</li>
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
<InfoTabsSection
activeInfoTab={activeInfoTab}
setActiveInfoTab={setActiveInfoTab}
showFullDocs={showFullDocs}
setShowFullDocs={setShowFullDocs}
/>
</div>
)
}

View File

@@ -0,0 +1,49 @@
export interface Component {
type: string
name: string
version: string
purl?: string
licenses?: { license: { id: string } }[]
category?: string
port?: string
description?: string
license?: string
sourceUrl?: string
}
export interface SBOMData {
bomFormat?: string
specVersion?: string
version?: number
metadata?: {
timestamp?: string
tools?: { vendor: string; name: string; version: string }[]
component?: { type: string; name: string; version: string }
}
components?: Component[]
}
export type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp'
export type InfoTabType = 'audit' | 'documentation'
export interface SbomStats {
totalInfra: number
totalSecurityTools: number
totalPython: number
totalGo: number
totalNode: number
totalUnity: number
totalCsharp: number
totalAll: number
databases: number
services: number
communication: number
game: number
}
export interface CategoryItem {
id: string
name: string
count: number
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import type { SBOMData, CategoryType, InfoTabType, SbomStats, CategoryItem, Component } from './types'
import {
INFRASTRUCTURE_COMPONENTS,
SECURITY_TOOLS,
PYTHON_PACKAGES,
GO_MODULES,
NODE_PACKAGES,
UNITY_PACKAGES,
CSHARP_PACKAGES,
} from './data'
export function useSbomData() {
const [sbomData, setSbomData] = useState<SBOMData | null>(null)
const [loading, setLoading] = useState(true)
const [activeCategory, setActiveCategory] = useState<CategoryType>('all')
const [searchTerm, setSearchTerm] = useState('')
const [activeInfoTab, setActiveInfoTab] = useState<InfoTabType>('audit')
const [showFullDocs, setShowFullDocs] = useState(false)
useEffect(() => {
loadSBOM()
}, [])
const loadSBOM = async () => {
setLoading(true)
try {
const res = await fetch('/api/v1/security/sbom')
if (res.ok) {
const data = await res.json()
setSbomData(data)
}
} catch (error) {
console.error('Failed to load SBOM:', error)
} finally {
setLoading(false)
}
}
const getAllComponents = (): Component[] => {
const infraComponents = INFRASTRUCTURE_COMPONENTS.map(c => ({ ...c, category: c.category || 'infrastructure' }))
const securityToolsComponents = SECURITY_TOOLS.map(c => ({ ...c, category: c.category || 'security-tool' }))
const pythonComponents = PYTHON_PACKAGES.map(c => ({ ...c, category: 'python' as const }))
const goComponents = GO_MODULES.map(c => ({ ...c, category: 'go' as const }))
const nodeComponents = NODE_PACKAGES.map(c => ({ ...c, category: 'nodejs' as const }))
const unityComponents = UNITY_PACKAGES.map(c => ({ ...c, category: 'unity' as const }))
const csharpComponents = CSHARP_PACKAGES.map(c => ({ ...c, category: 'csharp' as const }))
const dynamicPython = (sbomData?.components || []).map(c => ({ ...c, category: 'python' as const }))
return [
...infraComponents, ...securityToolsComponents, ...pythonComponents,
...goComponents, ...nodeComponents, ...unityComponents,
...csharpComponents, ...dynamicPython,
]
}
const filteredComponents = useMemo(() => {
let components: Component[]
if (activeCategory === 'all') {
components = getAllComponents()
} else if (activeCategory === 'infrastructure') {
components = INFRASTRUCTURE_COMPONENTS
} else if (activeCategory === 'security-tools') {
components = SECURITY_TOOLS
} else if (activeCategory === 'python') {
components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
} else if (activeCategory === 'go') {
components = GO_MODULES
} else if (activeCategory === 'nodejs') {
components = NODE_PACKAGES
} else if (activeCategory === 'unity') {
components = UNITY_PACKAGES
} else if (activeCategory === 'csharp') {
components = CSHARP_PACKAGES
} else {
components = getAllComponents()
}
if (searchTerm) {
const term = searchTerm.toLowerCase()
components = components.filter(c =>
c.name.toLowerCase().includes(term) ||
c.version.toLowerCase().includes(term) ||
(c.description?.toLowerCase().includes(term))
)
}
return components
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCategory, searchTerm, sbomData])
const stats: SbomStats = useMemo(() => ({
totalInfra: INFRASTRUCTURE_COMPONENTS.length,
totalSecurityTools: SECURITY_TOOLS.length,
totalPython: PYTHON_PACKAGES.length + (sbomData?.components?.length || 0),
totalGo: GO_MODULES.length,
totalNode: NODE_PACKAGES.length,
totalUnity: UNITY_PACKAGES.length,
totalCsharp: CSHARP_PACKAGES.length,
totalAll: INFRASTRUCTURE_COMPONENTS.length + SECURITY_TOOLS.length + PYTHON_PACKAGES.length + GO_MODULES.length + NODE_PACKAGES.length + UNITY_PACKAGES.length + CSHARP_PACKAGES.length + (sbomData?.components?.length || 0),
databases: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'database').length,
services: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'application').length,
communication: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'communication').length,
game: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'game').length,
}), [sbomData])
const categories: CategoryItem[] = useMemo(() => [
{ id: 'all', name: 'Alle', count: stats.totalAll },
{ id: 'infrastructure', name: 'Infrastruktur', count: stats.totalInfra },
{ id: 'security-tools', name: 'Security Tools', count: stats.totalSecurityTools },
{ id: 'python', name: 'Python', count: stats.totalPython },
{ id: 'go', name: 'Go', count: stats.totalGo },
{ id: 'nodejs', name: 'Node.js', count: stats.totalNode },
{ id: 'unity', name: 'Unity', count: stats.totalUnity },
{ id: 'csharp', name: 'C#', count: stats.totalCsharp },
], [stats])
const handleExport = () => {
const data = JSON.stringify({
...sbomData,
infrastructure: INFRASTRUCTURE_COMPONENTS
}, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `breakpilot-lehrer-sbom-${new Date().toISOString().split('T')[0]}.json`
a.click()
}
return {
sbomData,
loading,
activeCategory,
setActiveCategory,
searchTerm,
setSearchTerm,
activeInfoTab,
setActiveInfoTab,
showFullDocs,
setShowFullDocs,
filteredComponents,
stats,
categories,
handleExport,
}
}

View File

@@ -0,0 +1,244 @@
'use client'
interface DocumentationSectionProps {
showFullDocs: boolean
setShowFullDocs: (show: boolean) => void
}
export function DocumentationSection({ showFullDocs, setShowFullDocs }: DocumentationSectionProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Security Dokumentation
</h3>
<button
onClick={() => setShowFullDocs(!showFullDocs)}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2 text-sm font-medium"
>
<svg className={`w-4 h-4 transition-transform ${showFullDocs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
{showFullDocs ? 'Weniger anzeigen' : 'Vollstaendige Dokumentation'}
</button>
</div>
{/* Short Description */}
<div className="prose prose-slate max-w-none">
<p className="text-slate-600">
Das Security Dashboard bietet einen zentralen Ueberblick ueber alle DevSecOps-Aktivitaeten.
Es integriert 6 Security-Tools fuer umfassende Code- und Infrastruktur-Sicherheit:
Secrets Detection, Static Analysis (SAST), Dependency Scanning und SBOM-Generierung.
</p>
</div>
{/* Tool Quick Reference */}
<ToolQuickReference />
{/* Full Documentation (Expandable) */}
{showFullDocs && <FullDocumentation />}
</div>
</div>
)
}
function ToolQuickReference() {
const tools = [
{ bg: 'bg-red-50', icon: '🔑', name: 'Gitleaks', cat: 'Secrets', textName: 'text-red-800', textCat: 'text-red-600' },
{ bg: 'bg-blue-50', icon: '🔍', name: 'Semgrep', cat: 'SAST', textName: 'text-blue-800', textCat: 'text-blue-600' },
{ bg: 'bg-yellow-50', icon: '🐍', name: 'Bandit', cat: 'Python', textName: 'text-yellow-800', textCat: 'text-yellow-600' },
{ bg: 'bg-purple-50', icon: '🔒', name: 'Trivy', cat: 'Container', textName: 'text-purple-800', textCat: 'text-purple-600' },
{ bg: 'bg-green-50', icon: '🐛', name: 'Grype', cat: 'Dependencies', textName: 'text-green-800', textCat: 'text-green-600' },
{ bg: 'bg-orange-50', icon: '📦', name: 'Syft', cat: 'SBOM', textName: 'text-orange-800', textCat: 'text-orange-600' },
]
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mt-4">
{tools.map(t => (
<div key={t.name} className={`${t.bg} p-3 rounded-lg text-center`}>
<span className="text-lg">{t.icon}</span>
<p className={`text-xs font-medium ${t.textName} mt-1`}>{t.name}</p>
<p className={`text-xs ${t.textCat}`}>{t.cat}</p>
</div>
))}
</div>
)
}
function FullDocumentation() {
return (
<div className="mt-6 bg-slate-50 rounded-lg p-6 border border-slate-200">
<div className="prose prose-slate max-w-none prose-headings:text-slate-900 prose-p:text-slate-600 prose-li:text-slate-600">
<h3>1. Security Tools Uebersicht</h3>
<h4>🔑 Gitleaks - Secrets Detection</h4>
<p>Durchsucht die gesamte Git-Historie nach versehentlich eingecheckten Secrets wie API-Keys, Passwoertern und Tokens.</p>
<ul>
<li><strong>Scan-Bereich:</strong> Git-Historie, Commits, Branches</li>
<li><strong>Erkannte Secrets:</strong> AWS Keys, GitHub Tokens, Private Keys, Passwoerter</li>
<li><strong>Ausgabe:</strong> JSON-Report mit Fundstelle, Commit-Hash, Autor</li>
</ul>
<h4>🔍 Semgrep - Static Application Security Testing</h4>
<p>Fuehrt regelbasierte statische Code-Analyse durch, um Sicherheitsluecken und Anti-Patterns zu finden.</p>
<ul>
<li><strong>Unterstuetzte Sprachen:</strong> Python, JavaScript, TypeScript, Go, Java</li>
<li><strong>Regelsets:</strong> OWASP Top 10, CWE, Security Best Practices</li>
<li><strong>Findings:</strong> SQL Injection, XSS, Path Traversal, Insecure Deserialization</li>
</ul>
<h4>🐍 Bandit - Python Security Linter</h4>
<p>Spezialisierter Security-Linter fuer Python-Code mit Fokus auf haeufige Sicherheitsprobleme.</p>
<ul>
<li><strong>Checks:</strong> Hardcoded Passwords, SQL Injection, Shell Injection</li>
<li><strong>Severity Levels:</strong> LOW, MEDIUM, HIGH</li>
<li><strong>Confidence:</strong> LOW, MEDIUM, HIGH</li>
</ul>
<h4>🔒 Trivy - Container & Filesystem Scanner</h4>
<p>Scannt Container-Images und Dateisysteme auf bekannte Schwachstellen (CVEs).</p>
<ul>
<li><strong>Scan-Typen:</strong> Container Images, Filesystems, Git Repositories</li>
<li><strong>Datenbanken:</strong> NVD, GitHub Advisory, Alpine SecDB, RedHat OVAL</li>
<li><strong>Ausgabe:</strong> CVE-ID, Severity, Fixed Version, Description</li>
</ul>
<h4>🐛 Grype - Dependency Vulnerability Scanner</h4>
<p>Analysiert Software-Abhaengigkeiten auf bekannte Sicherheitsluecken.</p>
<ul>
<li><strong>Package Manager:</strong> npm, pip, go mod, Maven, Gradle</li>
<li><strong>Input:</strong> SBOM (CycloneDX/SPDX), Lockfiles, Container Images</li>
<li><strong>Matching:</strong> CPE-basiert, Package URL (purl)</li>
</ul>
<h4>📦 Syft - SBOM Generator</h4>
<p>Erstellt Software Bill of Materials (SBOM) fuer Compliance und Supply-Chain-Security.</p>
<ul>
<li><strong>Formate:</strong> CycloneDX (JSON/XML), SPDX, Syft JSON</li>
<li><strong>Erfassung:</strong> Packages, Lizenzen, Versionen, Checksums</li>
<li><strong>Compliance:</strong> NIS2, ISO 27001, DSGVO Art. 32</li>
</ul>
<h3>2. Severity-Klassifizierung</h3>
<table className="min-w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Severity</th>
<th className="text-left py-2">CVSS Score</th>
<th className="text-left py-2">Reaktionszeit</th>
<th className="text-left py-2">Beispiele</th>
</tr>
</thead>
<tbody>
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-red-100 text-red-800 rounded text-xs font-semibold">CRITICAL</span></td><td>9.0 - 10.0</td><td>Sofort (24h)</td><td>RCE, Auth Bypass, Exposed Secrets</td></tr>
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-orange-100 text-orange-800 rounded text-xs font-semibold">HIGH</span></td><td>7.0 - 8.9</td><td>1-3 Tage</td><td>SQL Injection, XSS, Path Traversal</td></tr>
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded text-xs font-semibold">MEDIUM</span></td><td>4.0 - 6.9</td><td>1-2 Wochen</td><td>Information Disclosure, CSRF</td></tr>
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-green-100 text-green-800 rounded text-xs font-semibold">LOW</span></td><td>0.1 - 3.9</td><td>Naechster Sprint</td><td>Minor Info Leak, Best Practice</td></tr>
<tr><td className="py-2"><span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs font-semibold">INFO</span></td><td>0.0</td><td>Optional</td><td>Empfehlungen, Hinweise</td></tr>
</tbody>
</table>
<h3>3. Scan-Workflow</h3>
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
{`┌─────────────────────────────────────────────────────────────┐
│ Security Scan Pipeline │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Secrets Detection (Gitleaks) │
│ └── Scannt Git-Historie nach API-Keys & Credentials │
│ ↓ │
│ 2. Static Analysis (Semgrep + Bandit) │
│ └── Code-Analyse auf Sicherheitsluecken │
│ ↓ │
│ 3. Dependency Scan (Trivy + Grype) │
│ └── CVE-Check aller Abhaengigkeiten │
│ ↓ │
│ 4. SBOM Generation (Syft) │
│ └── Software Bill of Materials erstellen │
│ ↓ │
│ 5. Report & Dashboard │
│ └── Ergebnisse aggregieren und visualisieren │
│ │
└─────────────────────────────────────────────────────────────┘`}
</pre>
<h3>4. Remediation-Strategien</h3>
<h4>Bei Secrets-Findings:</h4>
<ol>
<li>Secret sofort rotieren (neue API-Keys, Passwoerter)</li>
<li>Git-Historie bereinigen (BFG Repo-Cleaner oder git filter-branch)</li>
<li>Betroffene Systeme auf unauthorisierte Zugriffe pruefen</li>
<li>Secret-Scanning in Pre-Commit-Hooks aktivieren</li>
</ol>
<h4>Bei SAST-Findings:</h4>
<ol>
<li>Finding-Details und betroffene Code-Stelle analysieren</li>
<li>Empfohlene Fix-Strategie aus Semgrep-Dokumentation anwenden</li>
<li>Unit-Tests fuer den Fix schreiben</li>
<li>Code-Review durch Security-erfahrenen Entwickler</li>
</ol>
<h4>Bei Dependency-Vulnerabilities:</h4>
<ol>
<li>Pruefen ob ein Patch/Update verfuegbar ist</li>
<li>Abhaengigkeit auf gepatchte Version aktualisieren</li>
<li>Falls kein Patch: Workaround oder Alternative evaluieren</li>
<li>Temporaer: WAF-Regel als Mitigation</li>
</ol>
<h3>5. CI/CD Integration</h3>
<p>Security-Scans sind in die Gitea Actions Pipeline integriert:</p>
<ul>
<li><strong>Pre-Commit:</strong> Gitleaks (lokale Secrets-Pruefung)</li>
<li><strong>Pull Request:</strong> Semgrep, Bandit, Trivy (Blocking bei Critical)</li>
<li><strong>Main Branch:</strong> Full Scan + SBOM-Update</li>
<li><strong>Nightly:</strong> Dependency-Update-Check</li>
</ul>
<h3>6. Compliance-Mapping</h3>
<table className="min-w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Regulation</th>
<th className="text-left py-2">Artikel</th>
<th className="text-left py-2">Erfuellt durch</th>
</tr>
</thead>
<tbody>
<tr className="border-b"><td className="py-2">DSGVO</td><td>Art. 32</td><td>Alle Security-Scans, Vulnerability Management</td></tr>
<tr className="border-b"><td className="py-2">NIS2</td><td>Art. 21</td><td>SBOM, Supply-Chain-Security, Incident Response</td></tr>
<tr className="border-b"><td className="py-2">ISO 27001</td><td>A.12.6</td><td>Vulnerability Management, Patch Management</td></tr>
<tr><td className="py-2">OWASP</td><td>Top 10</td><td>SAST (Semgrep), Secrets Detection</td></tr>
</tbody>
</table>
<h3>7. API-Endpunkte</h3>
<table className="min-w-full text-sm font-mono">
<thead>
<tr className="border-b">
<th className="text-left py-2">Methode</th>
<th className="text-left py-2">Endpoint</th>
<th className="text-left py-2 font-sans">Beschreibung</th>
</tr>
</thead>
<tbody>
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/tools</td><td className="font-sans">Tool-Status abrufen</td></tr>
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/findings</td><td className="font-sans">Alle Findings abrufen</td></tr>
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/summary</td><td className="font-sans">Severity-Zusammenfassung</td></tr>
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/history</td><td className="font-sans">Scan-Historie</td></tr>
<tr className="border-b"><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/all</td><td className="font-sans">Full Scan starten</td></tr>
<tr><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/[tool]</td><td className="font-sans">Einzelnes Tool scannen</td></tr>
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
'use client'
import type { Finding } from '../types'
import { getSeverityBadge } from '../useSecurityDashboard'
interface FindingsTabProps {
filteredFindings: Finding[]
severityFilter: string | null
setSeverityFilter: (filter: string | null) => void
toolFilter: string | null
setToolFilter: (filter: string | null) => void
}
const SEVERITY_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
const TOOL_OPTIONS = ['gitleaks', 'semgrep', 'bandit', 'trivy', 'grype']
export function FindingsTab({
filteredFindings,
severityFilter,
setSeverityFilter,
toolFilter,
setToolFilter,
}: FindingsTabProps) {
return (
<div>
{/* Filters */}
<div className="flex gap-2 mb-4 flex-wrap">
<button
onClick={() => setSeverityFilter(null)}
className={`px-3 py-1 rounded-full text-sm ${!severityFilter ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
Alle
</button>
{SEVERITY_OPTIONS.map(sev => (
<button
key={sev}
onClick={() => setSeverityFilter(sev)}
className={`px-3 py-1 rounded-full text-sm ${severityFilter === sev ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
{sev}
</button>
))}
<span className="mx-2 border-l border-slate-300" />
{TOOL_OPTIONS.map(t => (
<button
key={t}
onClick={() => setToolFilter(toolFilter === t ? null : t)}
className={`px-3 py-1 rounded-full text-sm capitalize ${toolFilter === t ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
{t}
</button>
))}
</div>
{filteredFindings.length === 0 ? (
<div className="text-center py-8 text-slate-500">
Keine Findings mit diesem Filter gefunden.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Zeile</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
</tr>
</thead>
<tbody>
{filteredFindings.map((finding, idx) => (
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
</td>
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
<td className="py-3 px-4">
<div className="text-sm text-slate-900">{finding.title}</div>
{finding.message && (
<div className="text-xs text-slate-500 mt-1">{finding.message}</div>
)}
</td>
<td className="py-3 px-4 text-sm text-slate-500 font-mono max-w-xs truncate">
{finding.file || '-'}
</td>
<td className="py-3 px-4 text-sm text-slate-500">{finding.line || '-'}</td>
<td className="py-3 px-4 text-sm text-slate-500">
{finding.found_at ? new Date(finding.found_at).toLocaleDateString('de-DE') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import type { HistoryItem } from '../types'
import { getHistoryStatusColor } from '../useSecurityDashboard'
interface HistoryTabProps {
history: HistoryItem[]
}
export function HistoryTab({ history }: HistoryTabProps) {
if (history.length === 0) {
return (
<div className="text-center py-8 text-slate-500">
Keine Scan-Historie vorhanden.
</div>
)
}
return (
<div className="space-y-4">
<div className="relative">
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200" />
{history.map((item, idx) => (
<div key={idx} className="relative pl-10 pb-6">
<div className={`absolute left-2.5 w-3 h-3 rounded-full ${getHistoryStatusColor(item.status)}`} />
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex justify-between items-start mb-1">
<span className="font-semibold text-slate-900">{item.title}</span>
<span className="text-xs text-slate-500">
{new Date(item.timestamp).toLocaleString('de-DE')}
</span>
</div>
<p className="text-sm text-slate-600">{item.description}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,202 @@
'use client'
import type { MonitoringMetric, ActiveAlert } from '../types'
interface MonitoringTabProps {
monitoringMetrics: MonitoringMetric[]
activeAlerts: ActiveAlert[]
}
export function MonitoringTab({ monitoringMetrics, activeAlerts }: MonitoringTabProps) {
return (
<div className="space-y-6">
{/* Real-time Metrics */}
<MetricsGrid metrics={monitoringMetrics} />
{/* Active Alerts */}
<AlertsList alerts={activeAlerts} />
{/* Security Overview Cards */}
<SecurityOverviewCards />
{/* Link to CI/CD */}
<PipelineSecurityLink />
</div>
)
}
function MetricsGrid({ metrics }: { metrics: MonitoringMetric[] }) {
return (
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Security Metriken
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{metrics.map((metric, idx) => (
<div
key={idx}
className={`rounded-lg p-4 border ${
metric.status === 'critical' ? 'bg-red-50 border-red-200' :
metric.status === 'warning' ? 'bg-yellow-50 border-yellow-200' :
'bg-green-50 border-green-200'
}`}
>
<div className="flex justify-between items-start mb-2">
<span className="text-xs text-slate-600">{metric.name}</span>
<span className={`text-xs ${
metric.trend === 'up' ? 'text-red-600' :
metric.trend === 'down' ? 'text-green-600' :
'text-slate-500'
}`}>
{metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'}
</span>
</div>
<div className={`text-2xl font-bold ${
metric.status === 'critical' ? 'text-red-700' :
metric.status === 'warning' ? 'text-yellow-700' :
'text-green-700'
}`}>
{metric.value}
<span className="text-sm font-normal ml-1">{metric.unit}</span>
</div>
</div>
))}
</div>
</div>
)
}
function AlertsList({ alerts }: { alerts: ActiveAlert[] }) {
const unacknowledgedCount = alerts.filter(a => !a.acknowledged).length
return (
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
Aktive Alerts
{unacknowledgedCount > 0 && (
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
{unacknowledgedCount}
</span>
)}
</h3>
{alerts.length === 0 ? (
<div className="text-center py-8 bg-green-50 rounded-lg border border-green-200">
<span className="text-4xl block mb-2"></span>
<span className="text-green-700">Keine aktiven Security-Alerts</span>
</div>
) : (
<div className="space-y-2">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
alert.severity === 'critical' ? 'bg-red-50 border-red-200' :
alert.severity === 'high' ? 'bg-orange-50 border-orange-200' :
alert.severity === 'medium' ? 'bg-yellow-50 border-yellow-200' :
'bg-blue-50 border-blue-200'
}`}
>
<div className="flex items-center gap-4">
<span className={`px-2 py-1 text-xs font-semibold rounded uppercase ${
alert.severity === 'critical' ? 'bg-red-100 text-red-800' :
alert.severity === 'high' ? 'bg-orange-100 text-orange-800' :
alert.severity === 'medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-blue-100 text-blue-800'
}`}>
{alert.severity}
</span>
<div>
<div className="font-medium text-slate-900">{alert.title}</div>
<div className="text-xs text-slate-500">
{alert.source} {new Date(alert.timestamp).toLocaleString('de-DE')}
</div>
</div>
</div>
{!alert.acknowledged && (
<button className="px-3 py-1 text-xs bg-white border border-slate-300 rounded hover:bg-slate-50">
Bestaetigen
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
function SecurityOverviewCards() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Authentifizierung
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between"><span className="text-slate-600">Aktive Sessions</span><span className="font-medium">24</span></div>
<div className="flex justify-between"><span className="text-slate-600">Fehlgeschlagene Logins (24h)</span><span className="font-medium text-green-600">0</span></div>
<div className="flex justify-between"><span className="text-slate-600">2FA-Quote</span><span className="font-medium text-green-600">100%</span></div>
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
SSL/TLS
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between"><span className="text-slate-600">Zertifikate</span><span className="font-medium">5 aktiv</span></div>
<div className="flex justify-between"><span className="text-slate-600">Naechster Ablauf</span><span className="font-medium text-green-600">45 Tage</span></div>
<div className="flex justify-between"><span className="text-slate-600">TLS Version</span><span className="font-medium">1.3</span></div>
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Firewall
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between"><span className="text-slate-600">Blockierte IPs (24h)</span><span className="font-medium">12</span></div>
<div className="flex justify-between"><span className="text-slate-600">Rate Limit Hits</span><span className="font-medium text-yellow-600">7</span></div>
<div className="flex justify-between"><span className="text-slate-600">WAF Status</span><span className="font-medium text-green-600">Aktiv</span></div>
</div>
</div>
</div>
)
}
function PipelineSecurityLink() {
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<div className="font-medium text-blue-900">Pipeline Security</div>
<div className="text-sm text-blue-700">Security-Scans in CI/CD Pipelines und Container-Status</div>
</div>
</div>
<a
href="/infrastructure/ci-cd"
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
CI/CD Dashboard
</a>
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import type { ToolStatus, Finding, ScanType, TabId } from '../types'
import { TOOL_DESCRIPTIONS, TOOL_TO_SCAN_TYPE } from '../types'
import { getSeverityBadge, getStatusBadge } from '../useSecurityDashboard'
interface OverviewTabProps {
tools: ToolStatus[]
findings: Finding[]
scanning: string | null
onRunScan: (scanType: ScanType) => void
onSwitchTab: (tab: TabId) => void
}
export function OverviewTab({ tools, findings, scanning, onRunScan, onSwitchTab }: OverviewTabProps) {
return (
<div className="space-y-6">
{/* Tools Grid */}
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-4">DevSecOps Tools</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tools.map(tool => {
const info = TOOL_DESCRIPTIONS[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
const scanType = TOOL_TO_SCAN_TYPE[tool.name.toLowerCase()] || 'all'
return (
<div key={tool.name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{info.icon}</span>
<span className="font-semibold text-slate-900">{tool.name}</span>
</div>
<span className={getStatusBadge(tool.installed)}>
{tool.installed ? 'Installiert' : 'Nicht installiert'}
</span>
</div>
<p className="text-sm text-slate-600 mb-3">{info.desc}</p>
<div className="flex justify-between items-center text-xs text-slate-500">
<span>{tool.version || '-'}</span>
<span>Letzter Scan: {tool.last_run || 'Nie'}</span>
</div>
<button
onClick={() => onRunScan(scanType)}
disabled={scanning !== null || !tool.installed}
className={`mt-3 w-full px-3 py-1.5 text-sm border rounded transition-colors flex items-center justify-center gap-2 ${
scanning === scanType
? 'bg-orange-100 border-orange-300 text-orange-700'
: 'bg-white border-slate-300 hover:bg-slate-50 disabled:opacity-50'
}`}
>
{scanning === scanType ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-600" />
<span>Scan laeuft...</span>
</>
) : (
'Scan starten'
)}
</button>
</div>
)
})}
</div>
</div>
{/* Recent Findings */}
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-4">Aktuelle Findings</h3>
{findings.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<span className="text-4xl block mb-2">🎉</span>
Keine Findings gefunden. Das ist gut!
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
</tr>
</thead>
<tbody>
{findings.slice(0, 10).map((finding, idx) => (
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
</td>
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
<td className="py-3 px-4 text-sm text-slate-900">{finding.title}</td>
<td className="py-3 px-4 text-sm text-slate-500 font-mono">{finding.file || '-'}</td>
<td className="py-3 px-4 text-sm text-slate-500">
{finding.found_at ? new Date(finding.found_at).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
}) : '-'}
</td>
</tr>
))}
</tbody>
</table>
{findings.length > 10 && (
<button
onClick={() => onSwitchTab('findings')}
className="mt-4 text-sm text-orange-600 hover:text-orange-700"
>
Alle {findings.length} Findings anzeigen
</button>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import type { SeveritySummary, ScanType } from '../types'
interface SecurityHeaderProps {
overallStatus: { label: string; color: string }
summary: SeveritySummary
scanning: string | null
onRunScan: (scanType: ScanType) => void
}
export function SecurityHeader({ overallStatus, summary, scanning, onRunScan }: SecurityHeaderProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold text-slate-900">Security Status</h2>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${overallStatus.color}`}>
{overallStatus.label}
</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-2 text-sm text-slate-500">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Auto-Refresh aktiv
</span>
<button
onClick={() => onRunScan('all')}
disabled={scanning !== null}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
scanning === 'all'
? 'bg-orange-200 text-orange-800'
: 'bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50'
}`}
>
{scanning === 'all' ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-700" />
<span>Full Scan laeuft...</span>
</>
) : (
'Full Scan starten'
)}
</button>
</div>
</div>
{/* Severity Summary */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{([
{ key: 'critical', border: 'border-red-500', text: 'text-red-600', label: 'Critical' },
{ key: 'high', border: 'border-orange-500', text: 'text-orange-600', label: 'High' },
{ key: 'medium', border: 'border-yellow-500', text: 'text-yellow-600', label: 'Medium' },
{ key: 'low', border: 'border-green-500', text: 'text-green-600', label: 'Low' },
{ key: 'info', border: 'border-blue-500', text: 'text-blue-600', label: 'Info' },
{ key: 'total', border: 'border-slate-400', text: 'text-slate-700', label: 'Total' },
] as const).map(({ key, border, text, label }) => (
<div key={key} className={`border-l-4 ${border} bg-slate-50 rounded-r-lg p-4`}>
<div className={`text-3xl font-bold ${text}`}>{summary[key]}</div>
<div className="text-sm text-slate-600">{label}</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { ToolStatus, ScanType } from '../types'
import { TOOL_DESCRIPTIONS, TOOL_TO_SCAN_TYPE } from '../types'
import { getStatusBadge } from '../useSecurityDashboard'
interface ToolsTabProps {
tools: ToolStatus[]
scanning: string | null
onRunScan: (scanType: ScanType) => void
}
export function ToolsTab({ tools, scanning, onRunScan }: ToolsTabProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{tools.map(tool => {
const info = TOOL_DESCRIPTIONS[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
const scanType = TOOL_TO_SCAN_TYPE[tool.name.toLowerCase()] || 'all'
return (
<div key={tool.name} className="bg-white border border-slate-200 rounded-lg p-6">
<div className="flex justify-between items-start mb-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-2xl">{info.icon}</span>
<h3 className="text-lg font-semibold text-slate-900">{tool.name}</h3>
</div>
<p className="text-sm text-slate-600">{info.desc}</p>
</div>
<span className={getStatusBadge(tool.installed)}>
{tool.installed ? 'Installiert' : 'Nicht installiert'}
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Version:</span>
<span className="font-mono">{tool.version || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Letzter Scan:</span>
<span>{tool.last_run || 'Nie'}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Findings:</span>
<span className="font-semibold">{tool.last_findings}</span>
</div>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={() => onRunScan(scanType)}
disabled={scanning !== null || !tool.installed}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
scanning === scanType
? 'bg-orange-200 text-orange-800'
: 'bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50'
}`}
>
{scanning === scanType ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-700" />
<span>Scan laeuft...</span>
</>
) : (
'Scan starten'
)}
</button>
<button
onClick={() => window.open(`/api/v1/security/reports/${tool.name.toLowerCase()}`, '_blank')}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
Report
</button>
</div>
</div>
)
})}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
export interface ToolStatus {
name: string
installed: boolean
version: string | null
last_run: string | null
last_findings: number
}
export interface Finding {
id: string
tool: string
severity: string
title: string
message: string | null
file: string | null
line: number | null
found_at: string
}
export interface SeveritySummary {
critical: number
high: number
medium: number
low: number
info: number
total: number
}
export interface HistoryItem {
timestamp: string
title: string
description: string
status: string
}
export type ScanType = 'secrets' | 'sast' | 'deps' | 'containers' | 'sbom' | 'all'
export interface MonitoringMetric {
name: string
value: number
unit: string
status: 'ok' | 'warning' | 'critical'
trend: 'up' | 'down' | 'stable'
}
export interface ActiveAlert {
id: string
severity: 'critical' | 'high' | 'medium' | 'low'
title: string
source: string
timestamp: string
acknowledged: boolean
}
export type TabId = 'overview' | 'findings' | 'tools' | 'history' | 'monitoring'
export const SCAN_TYPE_LABELS: Record<ScanType, string> = {
secrets: 'Secrets (Gitleaks)',
sast: 'SAST (Semgrep + Bandit)',
deps: 'Dependencies (Trivy + Grype)',
containers: 'Container',
sbom: 'SBOM (Syft)',
all: 'Full Scan',
}
export const TOOL_DESCRIPTIONS: Record<string, { icon: string; desc: string }> = {
gitleaks: { icon: '🔑', desc: 'Secrets Detection in Git History' },
semgrep: { icon: '🔍', desc: 'Static Application Security Testing (SAST)' },
bandit: { icon: '🐍', desc: 'Python Security Linter' },
trivy: { icon: '🔒', desc: 'Container & Filesystem Vulnerability Scanner' },
grype: { icon: '🐛', desc: 'Vulnerability Scanner for Dependencies' },
syft: { icon: '📦', desc: 'SBOM Generator (CycloneDX/SPDX)' },
}
export const TOOL_TO_SCAN_TYPE: Record<string, ScanType> = {
gitleaks: 'secrets',
semgrep: 'sast',
bandit: 'sast',
trivy: 'deps',
grype: 'deps',
syft: 'sbom',
}

View File

@@ -0,0 +1,200 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import type {
ToolStatus,
Finding,
SeveritySummary,
MonitoringMetric,
ActiveAlert,
ScanType,
TabId,
} from './types'
import { SCAN_TYPE_LABELS } from './types'
const DEFAULT_METRICS: MonitoringMetric[] = [
{ name: 'API Latency', value: 45, unit: 'ms', status: 'ok', trend: 'stable' },
{ name: 'Auth Failures', value: 3, unit: '/h', status: 'ok', trend: 'down' },
{ name: 'Rate Limit Hits', value: 12, unit: '/h', status: 'warning', trend: 'up' },
{ name: 'Failed Logins', value: 0, unit: '/24h', status: 'ok', trend: 'stable' },
{ name: 'SSL Expiry', value: 45, unit: 'days', status: 'ok', trend: 'down' },
{ name: 'Open Ports', value: 8, unit: '', status: 'ok', trend: 'stable' },
]
export function useSecurityDashboard() {
const [tools, setTools] = useState<ToolStatus[]>([])
const [findings, setFindings] = useState<Finding[]>([])
const [summary, setSummary] = useState<SeveritySummary>({ critical: 0, high: 0, medium: 0, low: 0, info: 0, total: 0 })
const [history, setHistory] = useState<import('./types').HistoryItem[]>([])
const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [monitoringMetrics, setMonitoringMetrics] = useState<MonitoringMetric[]>([])
const [activeAlerts, setActiveAlerts] = useState<ActiveAlert[]>([])
const [severityFilter, setSeverityFilter] = useState<string | null>(null)
const [toolFilter, setToolFilter] = useState<string | null>(null)
const [showFullDocs, setShowFullDocs] = useState(false)
const [scanMessage, setScanMessage] = useState<string | null>(null)
const [lastScanTime, setLastScanTime] = useState<string | null>(null)
const fetchData = useCallback(async (showLoadingSpinner = false) => {
if (showLoadingSpinner) {
setLoading(true)
}
setError(null)
try {
const [toolsRes, findingsRes, summaryRes, historyRes] = await Promise.all([
fetch('/api/v1/security/tools'),
fetch('/api/v1/security/findings'),
fetch('/api/v1/security/summary'),
fetch('/api/v1/security/history'),
])
if (toolsRes.ok) setTools(await toolsRes.json())
if (findingsRes.ok) setFindings(await findingsRes.json())
if (summaryRes.ok) setSummary(await summaryRes.json())
if (historyRes.ok) setHistory(await historyRes.json())
const [metricsRes, alertsRes] = await Promise.all([
fetch('/api/v1/security/monitoring/metrics'),
fetch('/api/v1/security/monitoring/alerts'),
])
if (metricsRes.ok) {
setMonitoringMetrics(await metricsRes.json())
} else {
setMonitoringMetrics(DEFAULT_METRICS)
}
if (alertsRes.ok) {
setActiveAlerts(await alertsRes.json())
} else {
setActiveAlerts([])
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
// Initial load
useEffect(() => {
fetchData(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Auto-refresh every 60 seconds
useEffect(() => {
const interval = setInterval(() => fetchData(false), 60000)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const runScan = async (scanType: ScanType) => {
console.log(`Starting scan: ${scanType}`)
setScanning(scanType)
setError(null)
setScanMessage(`${SCAN_TYPE_LABELS[scanType]} wird gestartet...`)
try {
const response = await fetch(`/api/v1/security/scan/${scanType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
console.log(`Scan response status: ${response.status}`)
if (!response.ok) {
const errorText = await response.text()
console.error(`Scan error: ${errorText}`)
throw new Error(`Scan fehlgeschlagen: ${response.status} - ${errorText}`)
}
const result = await response.json()
console.log('Scan result:', result)
setScanMessage(`${SCAN_TYPE_LABELS[scanType]} laeuft im Hintergrund. Ergebnisse werden in wenigen Sekunden aktualisiert.`)
setLastScanTime(new Date().toLocaleTimeString('de-DE'))
setTimeout(() => { fetchData(false); setScanMessage(null) }, 5000)
setTimeout(() => fetchData(false), 15000)
setTimeout(() => fetchData(false), 30000)
} catch (err) {
console.error('Scan error:', err)
setError(err instanceof Error ? err.message : 'Scan fehlgeschlagen - Pruefe Browser-Konsole')
setScanMessage(null)
} finally {
setScanning(null)
}
}
const filteredFindings = findings.filter(f => {
if (severityFilter && f.severity.toUpperCase() !== severityFilter.toUpperCase()) return false
if (toolFilter && f.tool.toLowerCase() !== toolFilter.toLowerCase()) return false
return true
})
const overallStatus = getOverallStatus(summary)
return {
tools,
findings,
filteredFindings,
summary,
history,
loading,
scanning,
error,
activeTab,
setActiveTab,
monitoringMetrics,
activeAlerts,
severityFilter,
setSeverityFilter,
toolFilter,
setToolFilter,
showFullDocs,
setShowFullDocs,
scanMessage,
lastScanTime,
overallStatus,
runScan,
}
}
// --- Helper functions ---
function getOverallStatus(summary: SeveritySummary) {
if (summary.critical > 0) return { label: 'Critical Issues', color: 'bg-red-100 text-red-800' }
if (summary.high > 0) return { label: 'High Issues', color: 'bg-orange-100 text-orange-800' }
if (summary.medium > 0) return { label: 'Warnings', color: 'bg-yellow-100 text-yellow-800' }
return { label: 'Secure', color: 'bg-green-100 text-green-800' }
}
export function getSeverityBadge(severity: string) {
const base = 'px-3 py-1 rounded-full text-xs font-semibold uppercase'
switch (severity.toUpperCase()) {
case 'CRITICAL': return `${base} bg-red-100 text-red-800`
case 'HIGH': return `${base} bg-orange-100 text-orange-800`
case 'MEDIUM': return `${base} bg-yellow-100 text-yellow-800`
case 'LOW': return `${base} bg-green-100 text-green-800`
default: return `${base} bg-blue-100 text-blue-800`
}
}
export function getStatusBadge(installed: boolean) {
return installed
? 'px-2 py-1 rounded text-xs font-semibold bg-green-100 text-green-800'
: 'px-2 py-1 rounded text-xs font-semibold bg-red-100 text-red-800'
}
export function getHistoryStatusColor(status: string) {
switch (status) {
case 'success': return 'bg-green-500'
case 'warning': return 'bg-yellow-500'
case 'error': return 'bg-red-500'
default: return 'bg-slate-400'
}
}