All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 39s
CI/CD / test-python-backend-compliance (push) Successful in 32s
CI/CD / test-python-document-crawler (push) Successful in 20s
CI/CD / test-python-dsms-gateway (push) Successful in 16s
CI/CD / validate-canonical-controls (push) Successful in 9s
CI/CD / Deploy (push) Successful in 4s
863 v1-Controls (manuell geschrieben, ohne Rechtsgrundlage) werden als "Eigenentwicklung" gekennzeichnet und automatisch mit regulatorischen Controls (DSGVO, NIS2, OWASP etc.) per Embedding-Similarity abgeglichen. Backend: - Migration 080: v1_control_matches Tabelle (Cross-Reference) - v1_enrichment.py: Batch-Matching via BGE-M3 + Qdrant (Threshold 0.75) - 3 neue API-Endpoints: enrich-v1-matches, v1-matches, v1-enrichment-stats - 6 Tests (dry-run, execution, matches, pagination, detection) Frontend: - Orange "Eigenentwicklung"-Badge statt grauem "v1" (wenn kein Source) - "Regulatorische Abdeckung"-Sektion im ControlDetail mit Match-Karten - Side-by-Side V1CompareView (Eigenentwicklung vs. regulatorisch gedeckt) - Prev/Next Navigation durch alle Matches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
414 lines
17 KiB
TypeScript
414 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import {
|
|
Atom, Search, ChevronRight, ChevronLeft, Filter,
|
|
BarChart3, ChevronsLeft, ChevronsRight, ArrowUpDown,
|
|
Clock, RefreshCw,
|
|
} from 'lucide-react'
|
|
import {
|
|
CanonicalControl, BACKEND_URL,
|
|
SeverityBadge, StateBadge, CategoryBadge, TargetAudienceBadge,
|
|
GenerationStrategyBadge, ObligationTypeBadge, RegulationCountBadge,
|
|
CATEGORY_OPTIONS,
|
|
} from '../control-library/components/helpers'
|
|
import { ControlDetail } from '../control-library/components/ControlDetail'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface AtomicStats {
|
|
total_active: number
|
|
total_duplicate: number
|
|
by_domain: Array<{ domain: string; count: number }>
|
|
by_regulation: Array<{ regulation: string; count: number }>
|
|
avg_regulation_coverage: number
|
|
}
|
|
|
|
// =============================================================================
|
|
// ATOMIC CONTROLS PAGE
|
|
// =============================================================================
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
export default function AtomicControlsPage() {
|
|
const [controls, setControls] = useState<CanonicalControl[]>([])
|
|
const [totalCount, setTotalCount] = useState(0)
|
|
const [stats, setStats] = useState<AtomicStats | null>(null)
|
|
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Filters
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
const [severityFilter, setSeverityFilter] = useState<string>('')
|
|
const [domainFilter, setDomainFilter] = useState<string>('')
|
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
|
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest'>('id')
|
|
|
|
// Pagination
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
|
|
// Mode
|
|
const [mode, setMode] = useState<'list' | 'detail'>('list')
|
|
|
|
// Debounce search
|
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
useEffect(() => {
|
|
if (searchTimer.current) clearTimeout(searchTimer.current)
|
|
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
|
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
|
}, [searchQuery])
|
|
|
|
// Build query params
|
|
const buildParams = useCallback((extra?: Record<string, string>) => {
|
|
const p = new URLSearchParams()
|
|
p.set('control_type', 'atomic')
|
|
// Exclude duplicates — show only active masters
|
|
if (!extra?.release_state) {
|
|
// Don't filter by state for count queries that already have it
|
|
}
|
|
if (severityFilter) p.set('severity', severityFilter)
|
|
if (domainFilter) p.set('domain', domainFilter)
|
|
if (categoryFilter) p.set('category', categoryFilter)
|
|
if (debouncedSearch) p.set('search', debouncedSearch)
|
|
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
|
return p.toString()
|
|
}, [severityFilter, domainFilter, categoryFilter, debouncedSearch])
|
|
|
|
// Load stats
|
|
const loadStats = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=atomic-stats`)
|
|
if (res.ok) setStats(await res.json())
|
|
} catch { /* ignore */ }
|
|
}, [])
|
|
|
|
// Load controls page
|
|
const loadControls = useCallback(async () => {
|
|
try {
|
|
setLoading(true)
|
|
const sortField = sortBy === 'id' ? 'control_id' : 'created_at'
|
|
const sortOrder = sortBy === 'newest' ? 'desc' : 'asc'
|
|
const offset = (currentPage - 1) * PAGE_SIZE
|
|
|
|
const qs = buildParams({
|
|
sort: sortField,
|
|
order: sortOrder,
|
|
limit: String(PAGE_SIZE),
|
|
offset: String(offset),
|
|
})
|
|
|
|
const countQs = buildParams()
|
|
|
|
const [ctrlRes, countRes] = await Promise.all([
|
|
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
|
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
|
])
|
|
|
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
|
if (countRes.ok) {
|
|
const data = await countRes.json()
|
|
setTotalCount(data.total || 0)
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [buildParams, sortBy, currentPage])
|
|
|
|
// Initial load
|
|
useEffect(() => { loadStats() }, [loadStats])
|
|
useEffect(() => { loadControls() }, [loadControls])
|
|
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, categoryFilter, debouncedSearch, sortBy])
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
|
|
|
// Loading
|
|
if (loading && controls.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-violet-600 border-t-transparent" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<p className="text-red-600">{error}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// DETAIL MODE
|
|
if (mode === 'detail' && selectedControl) {
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex-1 overflow-hidden">
|
|
<ControlDetail
|
|
ctrl={selectedControl}
|
|
onBack={() => { setMode('list'); setSelectedControl(null) }}
|
|
onEdit={() => {}}
|
|
onDelete={() => {}}
|
|
onReview={() => {}}
|
|
onNavigateToControl={async (controlId: string) => {
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setSelectedControl(data)
|
|
}
|
|
} catch { /* ignore */ }
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =========================================================================
|
|
// LIST VIEW
|
|
// =========================================================================
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<Atom className="w-6 h-6 text-violet-600" />
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-gray-900">Atomare Controls</h1>
|
|
<p className="text-xs text-gray-500">
|
|
Deduplizierte atomare Controls mit Herkunftsnachweis
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => { loadControls(); loadStats() }}
|
|
className="p-2 text-gray-400 hover:text-violet-600"
|
|
title="Aktualisieren"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats Bar */}
|
|
{stats && (
|
|
<div className="grid grid-cols-4 gap-3 mb-4">
|
|
<div className="bg-violet-50 border border-violet-200 rounded-lg p-3">
|
|
<div className="text-2xl font-bold text-violet-700">{stats.total_active.toLocaleString('de-DE')}</div>
|
|
<div className="text-xs text-violet-500">Master Controls</div>
|
|
</div>
|
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
|
<div className="text-2xl font-bold text-gray-600">{stats.total_duplicate.toLocaleString('de-DE')}</div>
|
|
<div className="text-xs text-gray-500">Duplikate (entfernt)</div>
|
|
</div>
|
|
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
|
<div className="text-2xl font-bold text-indigo-700">{stats.by_regulation.length}</div>
|
|
<div className="text-xs text-indigo-500">Regulierungen</div>
|
|
</div>
|
|
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
|
<div className="text-2xl font-bold text-emerald-700">{stats.avg_regulation_coverage}</div>
|
|
<div className="text-xs text-emerald-500">Avg. Regulierungen / Control</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Atomare Controls durchsuchen (ID, Titel, Objective)..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Filter className="w-4 h-4 text-gray-400" />
|
|
<select
|
|
value={domainFilter}
|
|
onChange={e => setDomainFilter(e.target.value)}
|
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
|
>
|
|
<option value="">Domain</option>
|
|
{stats?.by_domain.map(d => (
|
|
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={severityFilter}
|
|
onChange={e => setSeverityFilter(e.target.value)}
|
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
|
>
|
|
<option value="">Schweregrad</option>
|
|
<option value="critical">Kritisch</option>
|
|
<option value="high">Hoch</option>
|
|
<option value="medium">Mittel</option>
|
|
<option value="low">Niedrig</option>
|
|
</select>
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={e => setCategoryFilter(e.target.value)}
|
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
|
>
|
|
<option value="">Kategorie</option>
|
|
{CATEGORY_OPTIONS.map(c => (
|
|
<option key={c.value} value={c.value}>{c.label}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-gray-300 mx-1">|</span>
|
|
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
|
<select
|
|
value={sortBy}
|
|
onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest')}
|
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
|
>
|
|
<option value="id">Sortierung: ID</option>
|
|
<option value="newest">Neueste zuerst</option>
|
|
<option value="oldest">Aelteste zuerst</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pagination Header */}
|
|
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
|
<span>
|
|
{totalCount} Controls gefunden
|
|
{stats && totalCount !== stats.total_active && ` (von ${stats.total_active.toLocaleString('de-DE')} Master Controls)`}
|
|
{loading && <span className="ml-2 text-violet-500">Lade...</span>}
|
|
</span>
|
|
<span>Seite {currentPage} von {totalPages}</span>
|
|
</div>
|
|
|
|
{/* Control List */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="space-y-3">
|
|
{controls.map((ctrl) => (
|
|
<button
|
|
key={ctrl.control_id}
|
|
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
|
|
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-violet-300 hover:shadow-sm transition-all group"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
|
<SeverityBadge severity={ctrl.severity} />
|
|
<StateBadge state={ctrl.release_state} />
|
|
<CategoryBadge category={ctrl.category} />
|
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
|
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
|
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
|
</div>
|
|
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
|
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
{ctrl.source_citation?.source && (
|
|
<>
|
|
<span className="text-xs text-blue-600">
|
|
{ctrl.source_citation.source}
|
|
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
|
</span>
|
|
<span className="text-gray-300">|</span>
|
|
</>
|
|
)}
|
|
{ctrl.parent_control_id && (
|
|
<>
|
|
<span className="text-xs text-violet-500">via {ctrl.parent_control_id}</span>
|
|
<span className="text-gray-300">|</span>
|
|
</>
|
|
)}
|
|
<Clock className="w-3 h-3 text-gray-400" />
|
|
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
|
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-violet-500 flex-shrink-0 mt-1 ml-4" />
|
|
</div>
|
|
</button>
|
|
))}
|
|
|
|
{controls.length === 0 && !loading && (
|
|
<div className="text-center py-12 text-gray-400 text-sm">
|
|
Keine atomaren Controls gefunden.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination Controls */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
|
<button
|
|
onClick={() => setCurrentPage(1)}
|
|
disabled={currentPage === 1}
|
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronsLeft className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
|
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
|
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
|
acc.push(p)
|
|
return acc
|
|
}, [])
|
|
.map((p, i) =>
|
|
p === 'dots' ? (
|
|
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
|
) : (
|
|
<button
|
|
key={p}
|
|
onClick={() => setCurrentPage(p as number)}
|
|
className={`w-8 h-8 text-sm rounded-lg ${
|
|
currentPage === p
|
|
? 'bg-violet-600 text-white'
|
|
: 'text-gray-600 hover:bg-violet-50 hover:text-violet-600'
|
|
}`}
|
|
>
|
|
{p}
|
|
</button>
|
|
)
|
|
)
|
|
}
|
|
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage(totalPages)}
|
|
disabled={currentPage === totalPages}
|
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronsRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|