Extract hooks, sub-components, and constants into colocated files to bring all three page.tsx files under the 500-LOC hard cap (225, 134, 111 LOC). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
293 lines
14 KiB
TypeScript
293 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { CanonicalControl, Framework, BACKEND_URL } from './helpers'
|
|
import { ControlsMeta, ControlFormData } from './types'
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
export function useControlLibrary() {
|
|
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
|
const [controls, setControls] = useState<CanonicalControl[]>([])
|
|
const [totalCount, setTotalCount] = useState(0)
|
|
const [meta, setMeta] = useState<ControlsMeta | 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 [stateFilter, setStateFilter] = useState<string>('')
|
|
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
|
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
|
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
|
const [sourceFilter, setSourceFilter] = useState<string>('')
|
|
const [typeFilter, setTypeFilter] = useState<string>('')
|
|
const [hideDuplicates, setHideDuplicates] = useState(true)
|
|
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
|
|
|
|
// CRUD state
|
|
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
// Generator state
|
|
const [showGenerator, setShowGenerator] = useState(false)
|
|
const [processedStats, setProcessedStats] = useState<Array<Record<string, unknown>>>([])
|
|
const [showStats, setShowStats] = useState(false)
|
|
|
|
// Pagination
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
|
|
// Review mode
|
|
const [reviewMode, setReviewMode] = useState(false)
|
|
const [reviewIndex, setReviewIndex] = useState(0)
|
|
const [reviewItems, setReviewItems] = useState<CanonicalControl[]>([])
|
|
const [reviewCount, setReviewCount] = useState(0)
|
|
const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates')
|
|
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
|
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
|
|
|
// V1 Compare mode
|
|
const [compareMode, setCompareMode] = useState(false)
|
|
const [compareV1Control, setCompareV1Control] = useState<CanonicalControl | null>(null)
|
|
const [compareMatches, setCompareMatches] = useState<Array<{
|
|
matched_control_id: string; matched_title: string; matched_objective: string
|
|
matched_severity: string; matched_category: string
|
|
matched_source: string | null; matched_article: string | null
|
|
matched_source_citation: Record<string, string> | null
|
|
similarity_score: number; match_rank: number; match_method: string
|
|
}>>([])
|
|
|
|
const [bulkProcessing, setBulkProcessing] = useState(false)
|
|
|
|
// Abort controllers
|
|
const metaAbortRef = useRef<AbortController | null>(null)
|
|
const controlsAbortRef = useRef<AbortController | null>(null)
|
|
|
|
// 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])
|
|
|
|
const buildParams = useCallback((extra?: Record<string, string>) => {
|
|
const p = new URLSearchParams()
|
|
if (severityFilter) p.set('severity', severityFilter)
|
|
if (domainFilter) p.set('domain', domainFilter)
|
|
if (stateFilter) p.set('release_state', stateFilter)
|
|
if (verificationFilter) p.set('verification_method', verificationFilter)
|
|
if (categoryFilter) p.set('category', categoryFilter)
|
|
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
|
if (audienceFilter) p.set('target_audience', audienceFilter)
|
|
if (sourceFilter) p.set('source', sourceFilter)
|
|
if (typeFilter) p.set('control_type', typeFilter)
|
|
if (hideDuplicates) p.set('exclude_duplicates', 'true')
|
|
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, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
|
|
|
const loadFrameworks = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
|
if (res.ok) setFrameworks(await res.json())
|
|
} catch { /* ignore */ }
|
|
}, [])
|
|
|
|
const loadMeta = useCallback(async () => {
|
|
if (metaAbortRef.current) metaAbortRef.current.abort()
|
|
const controller = new AbortController()
|
|
metaAbortRef.current = controller
|
|
try {
|
|
const qs = buildParams()
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
|
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
|
} catch (e) {
|
|
if (e instanceof DOMException && e.name === 'AbortError') return
|
|
}
|
|
}, [buildParams])
|
|
|
|
const loadControls = useCallback(async () => {
|
|
if (controlsAbortRef.current) controlsAbortRef.current.abort()
|
|
const controller = new AbortController()
|
|
controlsAbortRef.current = controller
|
|
try {
|
|
setLoading(true)
|
|
const sortField = sortBy === 'id' ? 'control_id' : sortBy === 'source' ? 'source' : 'created_at'
|
|
const sortOrder = sortBy === 'newest' ? 'desc' : sortBy === 'oldest' ? 'asc' : '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}`, { signal: controller.signal }),
|
|
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
|
])
|
|
if (!controller.signal.aborted) {
|
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
|
if (countRes.ok) {
|
|
const data = await countRes.json()
|
|
setTotalCount(data.total || 0)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
}
|
|
}, [buildParams, sortBy, currentPage])
|
|
|
|
const loadReviewCount = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setReviewCount(data.total || 0)
|
|
}
|
|
} catch { /* ignore */ }
|
|
}, [])
|
|
|
|
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
|
useEffect(() => { loadMeta() }, [loadMeta])
|
|
useEffect(() => { loadControls() }, [loadControls])
|
|
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
|
|
|
const fullReload = useCallback(async () => {
|
|
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
|
|
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
|
|
|
|
const handleCreate = async (data: ControlFormData) => {
|
|
setSaving(true)
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
|
})
|
|
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
|
await fullReload(); setMode('list')
|
|
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
|
}
|
|
|
|
const handleUpdate = async (data: ControlFormData) => {
|
|
if (!selectedControl) return
|
|
setSaving(true)
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
|
})
|
|
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
|
await fullReload(); setSelectedControl(null); setMode('list')
|
|
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
|
}
|
|
|
|
const handleDelete = async (controlId: string) => {
|
|
if (!confirm(`Control ${controlId} wirklich loeschen?`)) return
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' })
|
|
if (!res.ok && res.status !== 204) { alert('Fehler beim Loeschen'); return }
|
|
await fullReload(); setSelectedControl(null); setMode('list')
|
|
} catch { alert('Netzwerkfehler') }
|
|
}
|
|
|
|
const handleReview = async (controlId: string, action: string) => {
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=review&id=${controlId}`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }),
|
|
})
|
|
if (res.ok) {
|
|
await fullReload()
|
|
if (reviewMode) {
|
|
const remaining = reviewItems.filter(c => c.control_id !== controlId)
|
|
setReviewItems(remaining)
|
|
if (remaining.length > 0) {
|
|
const nextIdx = Math.min(reviewIndex, remaining.length - 1)
|
|
setReviewIndex(nextIdx); setSelectedControl(remaining[nextIdx])
|
|
} else { setReviewMode(false); setSelectedControl(null); setMode('list') }
|
|
} else { setSelectedControl(null); setMode('list') }
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const handleBulkReject = async (sourceState: string) => {
|
|
const count = stateFilter === sourceState ? totalCount : reviewCount
|
|
if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return
|
|
setBulkProcessing(true)
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ release_state: sourceState, action: 'reject' }),
|
|
})
|
|
if (res.ok) { const data = await res.json(); alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`); await fullReload() }
|
|
else { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) }
|
|
} catch { alert('Netzwerkfehler') } finally { setBulkProcessing(false) }
|
|
}
|
|
|
|
const loadProcessedStats = async () => {
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
|
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const enterReviewMode = async () => {
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
|
if (res.ok) {
|
|
const items: CanonicalControl[] = await res.json()
|
|
if (items.length > 0) {
|
|
const dupes = items.filter(c =>
|
|
c.generation_metadata?.similar_controls &&
|
|
Array.isArray(c.generation_metadata.similar_controls) &&
|
|
(c.generation_metadata.similar_controls as unknown[]).length > 0
|
|
)
|
|
const rule3 = items.filter(c =>
|
|
!c.generation_metadata?.similar_controls ||
|
|
!Array.isArray(c.generation_metadata.similar_controls) ||
|
|
(c.generation_metadata.similar_controls as unknown[]).length === 0
|
|
)
|
|
setReviewDuplicates(dupes); setReviewRule3(rule3)
|
|
const startTab = dupes.length > 0 ? 'duplicates' : 'rule3'
|
|
const startItems = startTab === 'duplicates' ? dupes : rule3
|
|
setReviewTab(startTab); setReviewItems(startItems); setReviewMode(true)
|
|
setReviewIndex(0); setSelectedControl(startItems[0]); setMode('detail')
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const switchReviewTab = (tab: 'duplicates' | 'rule3') => {
|
|
const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3
|
|
setReviewTab(tab); setReviewItems(items); setReviewIndex(0)
|
|
if (items.length > 0) setSelectedControl(items[0])
|
|
}
|
|
|
|
return {
|
|
// State
|
|
frameworks, controls, totalCount, meta, selectedControl, setSelectedControl,
|
|
loading, error, searchQuery, setSearchQuery, debouncedSearch,
|
|
severityFilter, setSeverityFilter, domainFilter, setDomainFilter,
|
|
stateFilter, setStateFilter, verificationFilter, setVerificationFilter,
|
|
categoryFilter, setCategoryFilter, evidenceTypeFilter, setEvidenceTypeFilter,
|
|
audienceFilter, setAudienceFilter, sourceFilter, setSourceFilter,
|
|
typeFilter, setTypeFilter, hideDuplicates, setHideDuplicates,
|
|
sortBy, setSortBy, mode, setMode, saving,
|
|
showGenerator, setShowGenerator, processedStats, showStats, setShowStats,
|
|
currentPage, setCurrentPage, totalPages,
|
|
reviewMode, setReviewMode, reviewIndex, setReviewIndex,
|
|
reviewItems, setReviewItems, reviewCount, reviewTab, setReviewTab,
|
|
reviewDuplicates, reviewRule3, bulkProcessing,
|
|
compareMode, setCompareMode, compareV1Control, setCompareV1Control, compareMatches, setCompareMatches,
|
|
// Actions
|
|
fullReload, loadControls, loadMeta, loadFrameworks, loadReviewCount,
|
|
loadProcessedStats, handleCreate, handleUpdate, handleDelete,
|
|
handleReview, handleBulkReject, enterReviewMode, switchReviewTab,
|
|
}
|
|
}
|